From ee0f5f6fd64d68d390421183a621e6fe63aa1bdc Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 7 Apr 2026 23:09:34 -0300 Subject: [PATCH 001/117] feature(#20): this commit introduces client aggregate with properties and methods for flow and redirect URI management --- .../Aggregates/Client.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs new file mode 100644 index 0000000..86005bc --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs @@ -0,0 +1,14 @@ +namespace HttpsRichardy.Federation.Domain.Aggregates; + +public sealed class Client : Aggregate +{ + public string Name { get; set; } = default!; + public string Secret { get; set; } = default!; + + public ICollection Flows { get; set; } = []; + public ICollection RedirectUris { get; set; } = []; + public ICollection Permissions { get; set; } = []; + + public bool SupportsFlow(Grant flow) => Flows.Contains(flow); + public bool HasRedirectUri(RedirectUri uri) => RedirectUris.Contains(uri); +} \ No newline at end of file From f60a4ba5a8c22a1d48ed8408c8f7fd1de397f5a1 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 7 Apr 2026 23:12:44 -0300 Subject: [PATCH 002/117] refactor(#20): remove unused client id and secret hash properties from realm aggregate --- .../HttpsRichardy.Federation.Domain/Aggregates/Realm.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Realm.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Realm.cs index cad3228..7bd1eaa 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Realm.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Realm.cs @@ -5,9 +5,6 @@ public sealed class Realm : Aggregate public string Name { get; set; } = default!; public string Description { get; set; } = default!; - public string ClientId { get; set; } = default!; - public string SecretHash { get; set; } = default!; - + public ICollection Clients { get; set; } = []; public ICollection Permissions { get; set; } = []; - public ICollection RedirectUris { get; set; } = []; } From 3487e55309374adcb991622664bb24abd38035ef Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 00:58:22 -0300 Subject: [PATCH 003/117] fix(#20): this commit introduces missing global using directive for System.Text --- .../Backend/Source/HttpsRichardy.Federation.Domain/Usings.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Usings.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Usings.cs index 6eae572..f792be8 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Usings.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Usings.cs @@ -1,3 +1,5 @@ +global using System.Text; + global using HttpsRichardy.Federation.Domain.Aggregates; global using HttpsRichardy.Federation.Domain.Concepts; global using HttpsRichardy.Federation.Domain.Filtering; From db0209a937abf4606cef10ca3c420f01b3d73612 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 01:00:57 -0300 Subject: [PATCH 004/117] feature(#20): this commit introduces client filters and client filters builder for managing client filter specifications --- .../Filtering/Builders/ClientFiltersBuilder.cs | 12 ++++++++++++ .../Filtering/ClientFilters.cs | 9 +++++++++ 2 files changed, 21 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/ClientFiltersBuilder.cs create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/ClientFilters.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/ClientFiltersBuilder.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/ClientFiltersBuilder.cs new file mode 100644 index 0000000..b2c12a1 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/ClientFiltersBuilder.cs @@ -0,0 +1,12 @@ +namespace HttpsRichardy.Federation.Domain.Filtering.Builders; + +public sealed class ClientFiltersBuilder : FiltersBuilderBase +{ + public ClientFiltersBuilder WithName(string? name) + { + if (!string.IsNullOrWhiteSpace(name)) + _filters.Name = name.Trim().Normalize(NormalizationForm.FormC); + + return this; + } +} \ No newline at end of file diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/ClientFilters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/ClientFilters.cs new file mode 100644 index 0000000..8c691bf --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/ClientFilters.cs @@ -0,0 +1,9 @@ +namespace HttpsRichardy.Federation.Domain.Filtering; + +public sealed class ClientFilters : Filters +{ + public string? Name { get; set; } + + public static ClientFilters WithoutFilters => new(); + public static ClientFiltersBuilder WithSpecifications() => new(); +} \ No newline at end of file From e12a8ccb1b581782c5bed3bf670a6e9650f2695c Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 01:04:16 -0300 Subject: [PATCH 005/117] feature(#20): this commit introduces client collection interface for client management operations --- .../Collections/IClientCollection.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Domain/Collections/IClientCollection.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Collections/IClientCollection.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Collections/IClientCollection.cs new file mode 100644 index 0000000..1eb1201 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Collections/IClientCollection.cs @@ -0,0 +1,14 @@ +namespace HttpsRichardy.Federation.Domain.Collections; + +public interface IClientCollection : IAggregateCollection +{ + public Task> GetClientsAsync( + ClientFilters filters, + CancellationToken cancellation = default + ); + + public Task CountClientsAsync( + ClientFilters filters, + CancellationToken cancellation = default + ); +} \ No newline at end of file From 50f2385e5bd95d2e612ff2fcca97f22e0dcf88c2 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 01:05:18 -0300 Subject: [PATCH 006/117] feature(#20): this commit introduces clients constant to collections for client management --- .../Constants/Collections.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Collections.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Collections.cs index 0c72913..8088aca 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Collections.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Collections.cs @@ -7,5 +7,6 @@ public static class Collections public const string Groups = "federation.groups"; public const string Tokens = "federation.tokens"; public const string Realms = "federation.realms"; + public const string Clients = "federation.clients"; public const string Secrets = "federation.secrets"; } From 68a61d20978d6d40f32e6cbe5abe7ab83e9999c1 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 01:06:16 -0300 Subject: [PATCH 007/117] refactor(#20): remove unused ClientId constant from Realm and its references in filters --- .../Constants/Documents.cs | 1 - .../Pipelines/RealmFiltersStage.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs index a27484e..665f7f8 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs @@ -30,7 +30,6 @@ public static class Group public static class Realm { public const string Name = nameof(Domain.Aggregates.Realm.Name); - public const string ClientId = nameof(Domain.Aggregates.Realm.ClientId); public const string IsDeleted = nameof(Domain.Aggregates.Realm.IsDeleted); public const string Id = "_id"; } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/RealmFiltersStage.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/RealmFiltersStage.cs index f812a25..ecf388d 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/RealmFiltersStage.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/RealmFiltersStage.cs @@ -8,7 +8,6 @@ public static PipelineDefinition FilterRealms( var definitions = new List> { FilterDefinitions.MatchIfNotEmpty(Documents.Realm.Name, filters.Name), - FilterDefinitions.MatchIfNotEmpty(Documents.Realm.ClientId, filters.ClientId), FilterDefinitions.MatchIfNotEmpty(Documents.Realm.Id, filters.Id), FilterDefinitions.MatchBool(Documents.Realm.IsDeleted, filters.IsDeleted) }; From 1f82b07aa3c36a605842fdff40a1be3f25d78180 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 01:08:19 -0300 Subject: [PATCH 008/117] feature(#20): this commit introduces client constants for client management in documents class --- .../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 665f7f8..7b7c765 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs @@ -34,6 +34,14 @@ public static class Realm public const string Id = "_id"; } + public static class Client + { + public const string Name = nameof(Domain.Aggregates.Client.Name); + public const string RealmId = nameof(Domain.Aggregates.Client.RealmId); + public const string IsDeleted = nameof(Domain.Aggregates.Client.IsDeleted); + public const string Id = "_id"; + } + public static class SecurityToken { public const string Value = nameof(Domain.Aggregates.SecurityToken.Value); From 45c476c7383df136cfdfb8b8adb80dd98a271997 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 01:08:46 -0300 Subject: [PATCH 009/117] feature(#20): introduces realm id property to client class for enhanced client management --- .../Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs index 86005bc..1e625ee 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs @@ -4,6 +4,7 @@ public sealed class Client : Aggregate { public string Name { get; set; } = default!; public string Secret { get; set; } = default!; + public string RealmId { get; set; } = default!; public ICollection Flows { get; set; } = []; public ICollection RedirectUris { get; set; } = []; From 15ed3b6be3ea3bed3d99a2cc4dcdcb6be78d6566 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 01:10:14 -0300 Subject: [PATCH 010/117] feature(#20): this commit introduces realm id property to client filters and corresponding builder method for enhanced filtering capabilities --- .../Filtering/Builders/ClientFiltersBuilder.cs | 8 ++++++++ .../Filtering/ClientFilters.cs | 1 + 2 files changed, 9 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/ClientFiltersBuilder.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/ClientFiltersBuilder.cs index b2c12a1..b3843e5 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/ClientFiltersBuilder.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/ClientFiltersBuilder.cs @@ -9,4 +9,12 @@ public ClientFiltersBuilder WithName(string? name) return this; } + + public ClientFiltersBuilder WithRealmId(string? realmId) + { + if (!string.IsNullOrWhiteSpace(realmId)) + _filters.RealmId = realmId.Trim().Normalize(NormalizationForm.FormC); + + return this; + } } \ No newline at end of file diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/ClientFilters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/ClientFilters.cs index 8c691bf..c74b320 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/ClientFilters.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/ClientFilters.cs @@ -3,6 +3,7 @@ namespace HttpsRichardy.Federation.Domain.Filtering; public sealed class ClientFilters : Filters { public string? Name { get; set; } + public string? RealmId { get; set; } public static ClientFilters WithoutFilters => new(); public static ClientFiltersBuilder WithSpecifications() => new(); From 372cedde6e2e9d9328350594c9c7520c6ff95292 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 01:13:06 -0300 Subject: [PATCH 011/117] feature(#20): this commit introduces client filters stage class for client filtering in pipelines --- .../Pipelines/ClientFiltersStage.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/ClientFiltersStage.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/ClientFiltersStage.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/ClientFiltersStage.cs new file mode 100644 index 0000000..2d250b6 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/ClientFiltersStage.cs @@ -0,0 +1,18 @@ +namespace HttpsRichardy.Federation.Infrastructure.Pipelines; + +public static class ClientFiltersStage +{ + public static PipelineDefinition FilterClients(this PipelineDefinition pipeline, + ClientFilters filters, IRealmProvider realmProvider) + { + var realm = realmProvider.GetCurrentRealm(); + var definitions = new List> + { + FilterDefinitions.MatchIfNotEmpty(Documents.Client.Name, filters.Name), + FilterDefinitions.MatchIfNotEmpty(Documents.Client.RealmId, realm?.Id), + FilterDefinitions.MatchBool(Documents.Client.IsDeleted, filters.IsDeleted) + }; + + return pipeline.Match(Builders.Filter.And(definitions)); + } +} \ No newline at end of file From 441d90953510053ef5c75fbc5c64caeb205da528 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 01:20:28 -0300 Subject: [PATCH 012/117] feature(#20): introduce client collection class for managing client data retrieval and counting --- .../Persistence/ClientCollection.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Persistence/ClientCollection.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Persistence/ClientCollection.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Persistence/ClientCollection.cs new file mode 100644 index 0000000..d5c948a --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Persistence/ClientCollection.cs @@ -0,0 +1,41 @@ +namespace HttpsRichardy.Federation.Infrastructure.Persistence; + +public sealed class ClientCollection(IMongoDatabase database, IRealmProvider realmProvider) : + AggregateCollection(database, Collections.Clients), IClientCollection +{ + public async Task> GetClientsAsync( + ClientFilters filters, CancellationToken cancellation = default) + { + var pipeline = PipelineDefinitionBuilder + .For() + .As() + .FilterClients(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 clients = bsonDocuments + .Select(bson => BsonSerializer.Deserialize(bson)) + .ToList(); + + return clients; + } + + public async Task CountClientsAsync( + ClientFilters filters, CancellationToken cancellation = default) + { + var pipeline = PipelineDefinitionBuilder + .For() + .As() + .FilterClients(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 183ff0183e8a212db8e76196fac9ec150e273a07 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 01:21:27 -0300 Subject: [PATCH 013/117] feature(#20): this commit introduces client collection registration for client management in data persistence --- .../Extensions/DataPersistenceExtension.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/DataPersistenceExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/DataPersistenceExtension.cs index a5a9b13..6652f0c 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/DataPersistenceExtension.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/DataPersistenceExtension.cs @@ -20,6 +20,7 @@ public static void AddDataPersistence(this IServiceCollection services, ISetting services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); } } From 95dc386295a06481ef12b97f3cc76f4201a75a70 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 01:24:07 -0300 Subject: [PATCH 014/117] feature(#20): introduce client indexes for enhanced database indexing and retrieval --- .../Extensions/IndexesExtension.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/IndexesExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/IndexesExtension.cs index e1602fe..f8556ca 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/IndexesExtension.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/IndexesExtension.cs @@ -13,6 +13,7 @@ public static void EnsureIndexes(this IMongoDatabase database) var tokenCollection = database.GetCollection("federation.tokens"); var realmCollection = database.GetCollection("federation.realms"); + var clientCollection = database.GetCollection("federation.clients"); var userIndexes = new[] { @@ -52,8 +53,12 @@ public static void EnsureIndexes(this IMongoDatabase database) var realmIndexes = new[] { - new CreateIndexModel(Builders.IndexKeys.Ascending(realm => realm.Name)), - new CreateIndexModel(Builders.IndexKeys.Ascending(realm => realm.ClientId)) + new CreateIndexModel(Builders.IndexKeys.Ascending(realm => realm.Name)) + }; + + var clientIndexes = new[] + { + new CreateIndexModel(Builders.IndexKeys.Ascending(client => client.RealmId)) }; userCollection.Indexes.CreateMany(userIndexes); @@ -61,5 +66,6 @@ public static void EnsureIndexes(this IMongoDatabase database) groupCollection.Indexes.CreateMany(groupIndexes); tokenCollection.Indexes.CreateMany(tokenIndexes); realmCollection.Indexes.CreateMany(realmIndexes); + clientCollection.Indexes.CreateMany(clientIndexes); } } From 6d861cec9c86aca4e3b6aa2443819830274b5f58 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 01:26:57 -0300 Subject: [PATCH 015/117] feature(#20): remove redundant realm filtering tests for client id and counting --- .../Persistence/RealmPersistenceTests.cs | 68 ------------------- 1 file changed, 68 deletions(-) diff --git a/Applications/Backend/Tests/Integration/Persistence/RealmPersistenceTests.cs b/Applications/Backend/Tests/Integration/Persistence/RealmPersistenceTests.cs index 72acbc6..0d62f96 100644 --- a/Applications/Backend/Tests/Integration/Persistence/RealmPersistenceTests.cs +++ b/Applications/Backend/Tests/Integration/Persistence/RealmPersistenceTests.cs @@ -178,74 +178,6 @@ public async Task WhenPaginatingTenRealms_ThenItMustReturnFiveRealmsPerPage() Assert.Equal(5, page2Results.Count); } - [Fact(DisplayName = "[infrastructure] - when filtering realms by client id, then only realms with that client id are returned")] - public async Task WhenFilteringRealmsByClientId_ThenOnlyRealmsFromThatClientAreReturned() - { - /* arrange: create realm with a specific client id */ - var clientId = "test-client-id"; - - var realm = _fixture.Build() - .With(realm => realm.Name, "HttpsRichardy") - .With(realm => realm.ClientId, clientId) - .With(realm => realm.IsDeleted, false) - .Create(); - - await _realmCollection.InsertAsync(realm); - - var filters = RealmFilters.WithSpecifications() - .WithClientId(clientId) - .WithName(realm.Name) - .Build(); - - /* act: query realms filtered by client id and name */ - var result = await _realmCollection.GetRealmsAsync(filters, CancellationToken.None); - var retrievedRealm = result.FirstOrDefault(); - - /* assert: only realm with matching client id is returned */ - Assert.NotNull(retrievedRealm); - - Assert.Equal(realm.Id, retrievedRealm.Id); - Assert.Equal(realm.Name, retrievedRealm.Name); - Assert.Equal(clientId, retrievedRealm.ClientId); - } - - [Fact(DisplayName = "[infrastructure] - when counting realms with filters, then count must reflect filtered records")] - public async Task WhenCountingRealmsWithFilters_ThenCountMustReflectFilteredRecords() - { - /* arrange: create 10 realms with varied clientId and IsDeleted */ - var clientId1 = "client1"; - var clientId2 = "client2"; - - var realms = new List(); - - for (int index = 0; index < 10; index++) - { - var realm = _fixture.Build() - .With(realm => realm.ClientId, index < 5 ? clientId1 : clientId2) // first 5 client1, last 5 client2 - .With(realm => realm.Name, $"realm{index}") - .With(realm => realm.IsDeleted, index % 3 == 0) // every third realm is deleted - .Create(); - - realms.Add(realm); - - await _realmCollection.InsertAsync(realm); - } - - /* act: count realms filtered by clientId1 and IsDeleted = false */ - var filters = RealmFilters.WithSpecifications() - .WithClientId(clientId1) - .WithIsDeleted(false) - .Build(); - - var filteredCount = await _realmCollection.CountAsync(filters, CancellationToken.None); - var expectedCount = realms.Count(realm => realm.ClientId == clientId1 && !realm.IsDeleted); - - /* assert: expected count of realms for client */ - Assert.Equal(expectedCount, filteredCount); - } - - #pragma warning disable S2325 - public async Task DisposeAsync() => await Task.CompletedTask; public async Task InitializeAsync() { From 456bda1db5c09eb0af9c6735d15b146902a8fc51 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 01:30:51 -0300 Subject: [PATCH 016/117] feature(#20): this commit introduces client persistence tests to verify database insertion and retrieval --- .../Persistence/ClientPersistenceTests.cs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 Applications/Backend/Tests/Integration/Persistence/ClientPersistenceTests.cs diff --git a/Applications/Backend/Tests/Integration/Persistence/ClientPersistenceTests.cs b/Applications/Backend/Tests/Integration/Persistence/ClientPersistenceTests.cs new file mode 100644 index 0000000..d7bbcab --- /dev/null +++ b/Applications/Backend/Tests/Integration/Persistence/ClientPersistenceTests.cs @@ -0,0 +1,53 @@ +namespace HttpsRichardy.Federation.TestSuite.Integration.Persistence; + +public sealed class ClientPersistenceTests : IClassFixture, IAsyncLifetime +{ + private readonly IClientCollection _clientCollection; + private readonly IMongoDatabase _database; + private readonly MongoDatabaseFixture _mongoFixture; + private readonly Mock _realmProvider = new(); + private readonly Fixture _fixture = new(); + + public ClientPersistenceTests(MongoDatabaseFixture mongoFixture) + { + _mongoFixture = mongoFixture; + _database = mongoFixture.Database; + + _clientCollection = new ClientCollection( + database: _database, + realmProvider: _realmProvider.Object + ); + } + + [Fact(DisplayName = "[infrastructure] - when inserting a client, then it must persist in the database")] + public async Task WhenInsertingAClient_ThenItMustPersistInTheDatabase() + { + /* arrange: create client and matching filter */ + var client = _fixture.Build() + .With(client => client.Name, "federation-admin") + .With(client => client.IsDeleted, false) + .Create(); + + var filters = ClientFilters.WithSpecifications() + .WithName(client.Name) + .Build(); + + /* act: persist client and query using name filter */ + await _clientCollection.InsertAsync(client); + + var result = await _clientCollection.GetClientsAsync(filters, CancellationToken.None); + var retrievedClient = result.FirstOrDefault(); + + /* assert: client must be retrieved with same id and name */ + Assert.NotNull(retrievedClient); + + Assert.Equal(client.Id, retrievedClient.Id); + Assert.Equal(client.Name, retrievedClient.Name); + } + + public async Task DisposeAsync() => await Task.CompletedTask; + public async Task InitializeAsync() + { + await _mongoFixture.CleanDatabaseAsync(); + } +} \ No newline at end of file From 5e7d2fed699df8ed8c7962f5b6b9ec3fcb719401 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 08:31:11 -0300 Subject: [PATCH 017/117] refactor(#20): replace realm parameter with client in security token and redirect uri policies --- .../Services/ISecurityTokenService.cs | 2 +- .../Policies/IRedirectUriPolicy.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecurityTokenService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecurityTokenService.cs index 4bf22a7..d996bfc 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecurityTokenService.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecurityTokenService.cs @@ -8,7 +8,7 @@ public Task> GenerateAccessTokenAsync( ); public Task> GenerateAccessTokenAsync( - Realm realm, + Client client, CancellationToken cancellation = default ); diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Policies/IRedirectUriPolicy.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Policies/IRedirectUriPolicy.cs index cf572ae..38da65b 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Policies/IRedirectUriPolicy.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Policies/IRedirectUriPolicy.cs @@ -8,7 +8,7 @@ public interface IRedirectUriPolicy { public Task EnsureRedirectUriIsAllowedAsync( - Realm realm, + Client client, RedirectUri redirectUri, CancellationToken cancellation = default ); From 41688a6b1151366f1e316d6a239aeb2294954604 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 08:32:10 -0300 Subject: [PATCH 018/117] feature(#20): refactor handler to use client collection instead of realm collection --- .../ClientCredentialsGrantHandler.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/ClientCredentialsGrantHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/ClientCredentialsGrantHandler.cs index 0c67693..345132c 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/ClientCredentialsGrantHandler.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/ClientCredentialsGrantHandler.cs @@ -1,30 +1,30 @@ namespace HttpsRichardy.Federation.Application.Handlers.Authorization; -public sealed class ClientCredentialsGrantHandler(IRealmCollection realmCollection, ISecurityTokenService tokenService) : +public sealed class ClientCredentialsGrantHandler(IClientCollection clientCollection, ISecurityTokenService tokenService) : IAuthorizationFlowHandler { public Grant Grant => Grant.ClientCredentials; public async Task> HandleAsync(ClientAuthenticationCredentials parameters, CancellationToken cancellation = default) { - var filters = new RealmFiltersBuilder() - .WithClientId(parameters.ClientId) + var filters = ClientFilters.WithSpecifications() + .WithName(parameters.ClientId) .Build(); - var realms = await realmCollection.GetRealmsAsync(filters, cancellation: cancellation); - var realm = realms.FirstOrDefault(); + var clients = await clientCollection.GetClientsAsync(filters, cancellation: cancellation); + var client = clients.FirstOrDefault(); - if (realm is null) + if (client is null) { return Result.Failure(AuthenticationErrors.ClientNotFound); } - if (parameters.ClientSecret != realm.SecretHash) + if (parameters.ClientSecret != client.Secret) { return Result.Failure(AuthenticationErrors.InvalidClientCredentials); } - var tokenResult = await tokenService.GenerateAccessTokenAsync(realm, cancellation); + var tokenResult = await tokenService.GenerateAccessTokenAsync(client, cancellation); if (tokenResult.IsFailure) { return Result.Failure(tokenResult.Error); From 12d5330bab0df2d85683b4fe9f750edb535a1e21 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 08:32:30 -0300 Subject: [PATCH 019/117] refactor(#20): simplify methods by removing clientId and secretHash parameters --- .../Mappers/RealmMapper.cs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/RealmMapper.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/RealmMapper.cs index 00021f9..5bfd7ad 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/RealmMapper.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/RealmMapper.cs @@ -2,12 +2,10 @@ namespace HttpsRichardy.Federation.Application.Mappers; public static class RealmMapper { - public static Realm AsRealm(RealmCreationScheme realm, string clientId, string secretHash) => new() + public static Realm AsRealm(RealmCreationScheme realm) => new() { Name = realm.Name, - Description = realm.Description ?? string.Empty, - ClientId = clientId, - SecretHash = secretHash + Description = realm.Description ?? string.Empty }; public static Realm AsRealm(RealmUpdateScheme payload, Realm realm) @@ -24,9 +22,7 @@ public static Realm AsRealm(RealmUpdateScheme payload, Realm realm) { Id = realm.Id.ToString(), Name = realm.Name, - Description = realm.Description, - ClientId = realm.ClientId, - ClientSecret = realm.SecretHash + Description = realm.Description }; public static RealmFilters AsFilters(RealmFetchParameters parameters) => new() From 0e177334132f0a2d24bed7804750e53774436138 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 08:33:07 -0300 Subject: [PATCH 020/117] feature(#20): this commit updates the method to use client instead of realm --- .../Policies/RedirectUriPolicy.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Policies/RedirectUriPolicy.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Policies/RedirectUriPolicy.cs index 7bfe17b..323905f 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Policies/RedirectUriPolicy.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Policies/RedirectUriPolicy.cs @@ -3,12 +3,12 @@ public sealed class RedirectUriPolicy : IRedirectUriPolicy { public Task EnsureRedirectUriIsAllowedAsync( - Realm realm, RedirectUri redirectUri, CancellationToken cancellation = default) + Client client, RedirectUri redirectUri, CancellationToken cancellation = default) { // according to oauth 2.0 spec (RFC 6749, section 3.1.2.3): // https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2.3 - var isAllowed = realm.RedirectUris.Contains(redirectUri); + var isAllowed = client.RedirectUris.Contains(redirectUri); return isAllowed ? Task.FromResult(Result.Success()) : From 7b4d031f5b49276600fd1bb0bde0eaea4c4deb08 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 08:50:21 -0300 Subject: [PATCH 021/117] feature(#20): this commit introduces client permissions to permissions and realm permissions constants --- .../Constants/Permissions.cs | 5 +++++ .../Constants/RealmPermissions.cs | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Common/Constants/Permissions.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Common/Constants/Permissions.cs index 4489d3d..7c23560 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Common/Constants/Permissions.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Common/Constants/Permissions.cs @@ -22,4 +22,9 @@ public static class Permissions public const string DeleteRealm = "federation.defaults.permissions.realm.delete"; public const string EditRealm = "federation.defaults.permissions.realm.update"; public const string ViewRealms = "federation.defaults.permissions.realm.view"; + + public const string CreateClient = "federation.defaults.permissions.client.create"; + public const string DeleteClient = "federation.defaults.permissions.client.delete"; + public const string EditClient = "federation.defaults.permissions.client.update"; + public const string ViewClients = "federation.defaults.permissions.client.view"; } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Common/Constants/RealmPermissions.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Common/Constants/RealmPermissions.cs index bf9cf67..851faa6 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Common/Constants/RealmPermissions.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Common/Constants/RealmPermissions.cs @@ -42,6 +42,11 @@ public static class RealmPermissions Permissions.CreateRealm, Permissions.DeleteRealm, Permissions.EditRealm, - Permissions.ViewRealms + Permissions.ViewRealms, + + Permissions.CreateClient, + Permissions.DeleteClient, + Permissions.EditClient, + Permissions.ViewClients ]; } From 98dbd273f38bc4eb87d6fab07a838d792522e20f Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 08:50:42 -0300 Subject: [PATCH 022/117] feature(#20): this commit introduces missing global using for domain.concepts --- .../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 a307eac..881cbd8 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs @@ -23,6 +23,7 @@ global using HttpsRichardy.Federation.Common.Constants; global using HttpsRichardy.Federation.Common.Configuration; global using HttpsRichardy.Federation.Domain.Errors; +global using HttpsRichardy.Federation.Domain.Concepts; global using HttpsRichardy.Federation.Application.Payloads.Group; global using HttpsRichardy.Federation.Application.Payloads.Identity; From 5f3836a0936250d9217265b88681b49367b2ba38 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 08:51:06 -0300 Subject: [PATCH 023/117] feature(#20): this commit introduces client support in bootstrapper extension and update client credentials handling --- .../Extensions/BootstrapperExtension.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs index b2d2ab4..b42ca31 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs @@ -8,6 +8,7 @@ public static async Task UseBootstrapperAsync(this IApplicationBuilder builder) using var scope = builder.ApplicationServices.CreateScope(); var realmCollection = scope.ServiceProvider.GetRequiredService(); + var clientCollection = scope.ServiceProvider.GetRequiredService(); var userCollection = scope.ServiceProvider.GetRequiredService(); var permissionCollection = scope.ServiceProvider.GetRequiredService(); @@ -16,9 +17,11 @@ public static async Task UseBootstrapperAsync(this IApplicationBuilder builder) var passwordHasher = scope.ServiceProvider.GetRequiredService(); var settings = scope.ServiceProvider.GetRequiredService(); - var realmCredentials = await credentialsGenerator.GenerateAsync("master", cancellation: default); + var clientCredentials = await credentialsGenerator.GenerateAsync("root", cancellation: default); + + var defaultRealm = new Realm { Name = "master" }; + var defaultClient = new Client { Name = "root", Flows = [Grant.ClientCredentials] }; - var defaultRealm = new Realm { Name = "master", ClientId = realmCredentials.ClientId }; var realmFilters = RealmFilters.WithSpecifications() .WithName("master") .Build(); @@ -31,7 +34,6 @@ public static async Task UseBootstrapperAsync(this IApplicationBuilder builder) return; } - defaultRealm.SecretHash = await passwordHasher.HashPasswordAsync(realmCredentials.ClientId + defaultRealm.Name); defaultRealm.Permissions = [.. RealmPermissions.SystemPermissions.Select(permissionName => new Permission { Id = Identifier.Generate(), @@ -39,10 +41,14 @@ public static async Task UseBootstrapperAsync(this IApplicationBuilder builder) RealmId = defaultRealm.Id })]; + defaultClient.Secret = await passwordHasher.HashPasswordAsync(clientCredentials.ClientId + defaultRealm.Name); + defaultClient.Permissions = [.. defaultRealm.Permissions]; + realmProvider.SetRealm(defaultRealm); await realmCollection.InsertAsync(defaultRealm); await permissionCollection.InsertManyAsync(defaultRealm.Permissions); + await clientCollection.InsertAsync(defaultClient); var userFilters = UserFilters.WithSpecifications() .WithUsername(settings.Administration.Username) From 462cf095a18425550ecb8e84b929b74bb16c59ab Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 08:53:24 -0300 Subject: [PATCH 024/117] feature(#20): this commit introduces default client to realm and set permissions in bootstrapper extension --- .../Extensions/BootstrapperExtension.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs index b42ca31..3a3c846 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs @@ -44,6 +44,8 @@ public static async Task UseBootstrapperAsync(this IApplicationBuilder builder) defaultClient.Secret = await passwordHasher.HashPasswordAsync(clientCredentials.ClientId + defaultRealm.Name); defaultClient.Permissions = [.. defaultRealm.Permissions]; + defaultRealm.Clients = [defaultClient]; + realmProvider.SetRealm(defaultRealm); await realmCollection.InsertAsync(defaultRealm); From 4f6d7200de68b3da00a0149aabcb5066b182e30c Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 08:56:15 -0300 Subject: [PATCH 025/117] feature(#20): this commit updates handler to use client collection instead of realm collection --- .../Handlers/Authorization/AuthorizationHandler.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/AuthorizationHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/AuthorizationHandler.cs index 96c6028..fea022f 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/AuthorizationHandler.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/AuthorizationHandler.cs @@ -1,16 +1,16 @@ namespace HttpsRichardy.Federation.Application.Handlers.Authorization; -public sealed class AuthorizationHandler(IRealmCollection realmCollection, IRedirectUriPolicy redirectUriPolicy) : +public sealed class AuthorizationHandler(IClientCollection clientCollection, IRedirectUriPolicy redirectUriPolicy) : IDispatchHandler> { public async Task> HandleAsync( AuthorizationParameters parameters, CancellationToken cancellation = default) { - var filters = new RealmFiltersBuilder() - .WithClientId(parameters.ClientId) + var filters = ClientFilters.WithSpecifications() + .WithIdentifier(parameters.ClientId) .Build(); - var clients = await realmCollection.GetRealmsAsync(filters, cancellation); + var clients = await clientCollection.GetClientsAsync(filters, cancellation); var client = clients.FirstOrDefault(); if (client is null) From ccdd7772fe89a58147e8a30def357f8b6e63828d Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 09:08:38 -0300 Subject: [PATCH 026/117] feature(#20): introduces client creation scheme and its validator for client creation payload --- .../Payloads/Client/ClientCreationScheme.cs | 9 ++++++ .../Client/ClientCreationSchemeValidator.cs | 31 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientCreationScheme.cs create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Client/ClientCreationSchemeValidator.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientCreationScheme.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientCreationScheme.cs new file mode 100644 index 0000000..17e5967 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientCreationScheme.cs @@ -0,0 +1,9 @@ +namespace HttpsRichardy.Federation.Application.Payloads.Client; + +public sealed record ClientCreationScheme : IDispatchable +{ + public string Name { get; init; } = default!; + + public IEnumerable Flows { get; init; } = []; + public IEnumerable RedirectUris { get; init; } = []; +} \ No newline at end of file diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Client/ClientCreationSchemeValidator.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Client/ClientCreationSchemeValidator.cs new file mode 100644 index 0000000..ed59eeb --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Client/ClientCreationSchemeValidator.cs @@ -0,0 +1,31 @@ +namespace HttpsRichardy.Federation.Application.Validators.Client; + +public sealed class ClientCreationSchemeValidator : AbstractValidator +{ + public ClientCreationSchemeValidator() + { + RuleFor(client => client.Name) + .NotEmpty() + .WithMessage("client name must not be empty.") + .MaximumLength(100) + .WithMessage("client name must not exceed 100 characters.") + .MinimumLength(3) + .WithMessage("client name must be at least 3 characters long."); + + RuleFor(client => client.Flows) + .NotEmpty() + .WithMessage("client must have at least one flow."); + + RuleForEach(client => client.Flows) + .IsInEnum() + .WithMessage("client flow must be a valid grant type."); + + When(client => client.RedirectUris is not null && client.RedirectUris.Any(), () => + { + RuleForEach(client => client.RedirectUris) + .Must(uri => !string.IsNullOrWhiteSpace(uri) && Uri.TryCreate(uri, UriKind.Absolute, out var parsed) && + (parsed.Scheme == Uri.UriSchemeHttp || parsed.Scheme == Uri.UriSchemeHttps)) + .WithMessage("redirect uri must be a valid url."); + }); + } +} \ No newline at end of file From 56d469d35477bf584fda0cb47f52d29aee8a689f Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 09:10:39 -0300 Subject: [PATCH 027/117] feature(#20): introduces missing global using for client payloads and validators --- .../HttpsRichardy.Federation.Infrastructure.IoC/Usings.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Usings.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Usings.cs index f5010ba..006d9d5 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Usings.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Usings.cs @@ -20,7 +20,9 @@ global using HttpsRichardy.Federation.Application.Payloads.Permission; global using HttpsRichardy.Federation.Application.Payloads.Realm; global using HttpsRichardy.Federation.Application.Payloads.User; +global using HttpsRichardy.Federation.Application.Payloads.Client; +global using HttpsRichardy.Federation.Application.Validators.Client; global using HttpsRichardy.Federation.Application.Validators.Permission; global using HttpsRichardy.Federation.Application.Validators.Group; global using HttpsRichardy.Federation.Application.Validators.Identity; From aa03e397383a7977300ecb44cd24e4a03996b4c9 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 09:10:53 -0300 Subject: [PATCH 028/117] feature(#20): introduces client creation scheme validator to validation extension --- .../Extensions/ValidationExtension.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ValidationExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ValidationExtension.cs index c5dd27d..7049c70 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ValidationExtension.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ValidationExtension.cs @@ -20,6 +20,8 @@ public static void AddValidators(this IServiceCollection services) services.AddTransient, RealmCreationValidator>(); services.AddTransient, RealmUpdateValidator>(); + services.AddTransient, ClientCreationSchemeValidator>(); + services.AddTransient, AssignUserPermissionValidator>(); services.AddTransient, AssignRealmPermissionValidator>(); } From 63d2f33772ee376c59dd0f9151594a88e8494c6e Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Wed, 8 Apr 2026 09:14:04 -0300 Subject: [PATCH 029/117] refactor(#20): simplify realm creation by removing unnecessary parameters in RealmMapper --- .../Handlers/Realm/RealmCreationHandler.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 263e4f0..63d9641 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Realm/RealmCreationHandler.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Realm/RealmCreationHandler.cs @@ -17,11 +17,7 @@ public async Task> HandleAsync( } var credentials = await credentialsGenerator.GenerateAsync(parameters.Name, cancellation: cancellation); - var realm = RealmMapper.AsRealm( - realm: parameters, - clientId: credentials.ClientId, - secretHash: credentials.ClientSecret - ); + var realm = RealmMapper.AsRealm(parameters); var masterFilters = RealmFilters.WithSpecifications() .WithName("master") From c1a4abf64997cedddcd244cbe8657db57fd779d3 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 9 Apr 2026 20:38:28 -0300 Subject: [PATCH 030/117] =?UTF-8?q?feature(#20):=20this=20commit=20introdu?= =?UTF-8?q?ces=20the=20=E2=80=9Cclient=20id=E2=80=9D=20property=20for=20pu?= =?UTF-8?q?blic=20client=20identification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HttpsRichardy.Federation.Domain/Aggregates/Client.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs index 1e625ee..1009f07 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs @@ -4,7 +4,9 @@ public sealed class Client : Aggregate { public string Name { get; set; } = default!; public string Secret { get; set; } = default!; + public string RealmId { get; set; } = default!; + public string ClientId { get; set; } = default!; public ICollection Flows { get; set; } = []; public ICollection RedirectUris { get; set; } = []; @@ -12,4 +14,4 @@ public sealed class Client : Aggregate public bool SupportsFlow(Grant flow) => Flows.Contains(flow); public bool HasRedirectUri(RedirectUri uri) => RedirectUris.Contains(uri); -} \ No newline at end of file +} From 063f04e8a271b5c26b275f8b47ba66d1f1bd13e5 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 9 Apr 2026 20:39:22 -0300 Subject: [PATCH 031/117] feature(#20): this commit introduces the "client id" query parameter to search for clients by id. --- .../Filtering/Builders/ClientFiltersBuilder.cs | 8 ++++---- .../Filtering/ClientFilters.cs | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/ClientFiltersBuilder.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/ClientFiltersBuilder.cs index b3843e5..75dd8f6 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/ClientFiltersBuilder.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/ClientFiltersBuilder.cs @@ -10,11 +10,11 @@ public ClientFiltersBuilder WithName(string? name) return this; } - public ClientFiltersBuilder WithRealmId(string? realmId) + public ClientFiltersBuilder WithClientId(string? clientId) { - if (!string.IsNullOrWhiteSpace(realmId)) - _filters.RealmId = realmId.Trim().Normalize(NormalizationForm.FormC); + if (!string.IsNullOrWhiteSpace(clientId)) + _filters.ClientId = clientId.Trim().Normalize(NormalizationForm.FormC); return this; } -} \ No newline at end of file +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/ClientFilters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/ClientFilters.cs index c74b320..abc79ba 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/ClientFilters.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/ClientFilters.cs @@ -3,8 +3,9 @@ namespace HttpsRichardy.Federation.Domain.Filtering; public sealed class ClientFilters : Filters { public string? Name { get; set; } + public string? ClientId { get; set; } public string? RealmId { get; set; } public static ClientFilters WithoutFilters => new(); public static ClientFiltersBuilder WithSpecifications() => new(); -} \ No newline at end of file +} From 1d6efdd85e689621518c38f948f89ae09710c0d2 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 9 Apr 2026 20:40:06 -0300 Subject: [PATCH 032/117] feature(#20): )this commit introduces the "client id" property to the BSON document representation. --- .../Constants/Documents.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs index 7b7c765..b8d7dbf 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs @@ -38,6 +38,7 @@ public static class Client { public const string Name = nameof(Domain.Aggregates.Client.Name); public const string RealmId = nameof(Domain.Aggregates.Client.RealmId); + public const string ClientId = nameof(Domain.Aggregates.Client.ClientId); public const string IsDeleted = nameof(Domain.Aggregates.Client.IsDeleted); public const string Id = "_id"; } From acb4f3c1f20cd3dba65b4178a71b23cf2577ae68 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 9 Apr 2026 20:40:48 -0300 Subject: [PATCH 033/117] feature(#20): this commit introduces the "client id" query parameter to the filter definition aggregation. --- .../Pipelines/ClientFiltersStage.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/ClientFiltersStage.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/ClientFiltersStage.cs index 2d250b6..0b22044 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/ClientFiltersStage.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/ClientFiltersStage.cs @@ -9,10 +9,11 @@ public static PipelineDefinition FilterClients(this Pipeli var definitions = new List> { FilterDefinitions.MatchIfNotEmpty(Documents.Client.Name, filters.Name), + FilterDefinitions.MatchIfNotEmpty(Documents.Client.ClientId, filters.ClientId), FilterDefinitions.MatchIfNotEmpty(Documents.Client.RealmId, realm?.Id), FilterDefinitions.MatchBool(Documents.Client.IsDeleted, filters.IsDeleted) }; return pipeline.Match(Builders.Filter.And(definitions)); } -} \ No newline at end of file +} From 2c70a95363e545f4b66ffa8685bdfcf2e438763c Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 9 Apr 2026 20:42:07 -0300 Subject: [PATCH 034/117] feature(#20): this commit introduces new unique and composite query indexes for "client id". --- .../Extensions/IndexesExtension.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/IndexesExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/IndexesExtension.cs index f8556ca..8f4cf30 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/IndexesExtension.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/IndexesExtension.cs @@ -58,7 +58,11 @@ public static void EnsureIndexes(this IMongoDatabase database) var clientIndexes = new[] { - new CreateIndexModel(Builders.IndexKeys.Ascending(client => client.RealmId)) + new CreateIndexModel(Builders.IndexKeys.Ascending(client => client.RealmId)), + new CreateIndexModel(Builders.IndexKeys.Ascending(client => client.ClientId)), + new CreateIndexModel(Builders.IndexKeys + .Ascending(client => client.RealmId) + .Ascending(client => client.ClientId)) }; userCollection.Indexes.CreateMany(userIndexes); From d52224689d1772f658f29d8c66dc52924ceb683c Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 9 Apr 2026 20:56:23 -0300 Subject: [PATCH 035/117] feature(#20): this commit introduces a new mapper dedicated to the clients module. --- .../Mappers/ClientMapper.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/ClientMapper.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/ClientMapper.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/ClientMapper.cs new file mode 100644 index 0000000..4622930 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/ClientMapper.cs @@ -0,0 +1,14 @@ +namespace HttpsRichardy.Federation.Application.Mappers; + +public static class ClientMapper +{ + public static Client AsClient(this ClientCreationScheme client, ClientCredentials credentials, Realm realm) => new() + { + Name = client.Name, + RealmId = realm.Id, + ClientId = credentials.ClientId, + Secret = credentials.ClientSecret, + Flows = [.. client.Flows], + RedirectUris = [.. client.RedirectUris.Select(uri => new RedirectUri(uri))] + }; +} From 839f47e0cea8e621f168ebd984548f1c8f7f2a1b Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 9 Apr 2026 20:57:41 -0300 Subject: [PATCH 036/117] feature(#20): this commit introduces a new handler responsible for creating clients for realms. --- .../Handlers/Client/ClientCreationHandler.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientCreationHandler.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientCreationHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientCreationHandler.cs new file mode 100644 index 0000000..13f821a --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientCreationHandler.cs @@ -0,0 +1,17 @@ +namespace HttpsRichardy.Federation.Application.Handlers.Client; + +public sealed class ClientCreationHandler(IClientCredentialsGenerator credentialsGenerator, IRealmProvider realmProvider, IClientCollection clientCollection) : + IDispatchHandler +{ + public async Task HandleAsync(ClientCreationScheme parameters, CancellationToken cancellation = default) + { + var realm = realmProvider.GetCurrentRealm(); + var credentials = await credentialsGenerator.GenerateAsync(parameters.Name, cancellation); + + var client = parameters.AsClient(credentials, realm); + + await clientCollection.InsertAsync(client, cancellation: cancellation); + + return Result.Success(); + } +} From a51e1ad212063e03fa373809eb07549bc5eb1246 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 9 Apr 2026 20:58:55 -0300 Subject: [PATCH 037/117] feature(#20): this commit adds a forgotten global usage directive to the client payloads namespace. --- .../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 881cbd8..b82f8a5 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs @@ -32,6 +32,7 @@ global using HttpsRichardy.Federation.Application.Payloads.Realm; global using HttpsRichardy.Federation.Application.Payloads.User; global using HttpsRichardy.Federation.Application.Payloads.Connect; +global using HttpsRichardy.Federation.Application.Payloads.Client; global using HttpsRichardy.Federation.Application.Payloads.Common; global using HttpsRichardy.Federation.Application.Providers; From 6f133a956100ea54b5f1c6eff026d8d7c436d164 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 9 Apr 2026 21:17:39 -0300 Subject: [PATCH 038/117] fix(#20): this commit fixes the breaking changes in the interface, aligning the implementation with the current contract. --- .../Security/JwtSecurityTokenService.cs | 13 ++++++------- 1 file changed, 6 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 1cf6472..a7b6106 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs @@ -65,14 +65,13 @@ public async Task> GenerateAccessTokenAsync(User user, Can return Result.Success(securityToken); } - public async Task> GenerateAccessTokenAsync(Realm realm, CancellationToken cancellation = default) + public async Task> GenerateAccessTokenAsync(Client client, CancellationToken cancellation = default) { var tokenHandler = new JwtSecurityTokenHandler(); var claims = new ClaimsBuilder() - .WithRealmId(realm.Id.ToString()) - .WithRealmName(realm.Name) - .WithClientId(realm.ClientId) - .WithPermissions(realm.Permissions) + .WithRealmId(client.RealmId) + .WithRealmName(client.Name) + .WithPermissions(client.Permissions) .Build(); var privateKey = await GetPrivateKeyAsync(cancellation); @@ -82,7 +81,7 @@ public async Task> GenerateAccessTokenAsync(Realm realm, C var tokenDescriptor = new SecurityTokenDescriptor { Issuer = host.Address.ToString().TrimEnd('/'), - Audience = realm.Name, + Audience = client.Name, Subject = claimsIdentity, SigningCredentials = credentials, NotBefore = DateTime.UtcNow.AddSeconds(-30), @@ -220,4 +219,4 @@ private async Task GetPublicKeyAsync(CancellationToken cancellat var secret = await secretCollection.GetSecretAsync(cancellation); return Common.Utilities.RsaHelper.CreateSecurityKeyFromPublicKey(secret.PublicKey); } -} \ No newline at end of file +} From d8710b8f06206b6270fbe473f06bb9b48dbc5289 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 9 Apr 2026 21:18:19 -0300 Subject: [PATCH 039/117] feature(#20): this commit introduces a new controller responsible for managing clients. --- .../Controllers/ClientsController.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs new file mode 100644 index 0000000..ad17aa1 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs @@ -0,0 +1,22 @@ +namespace HttpsRichardy.Federation.WebApi.Controllers; + +[ApiController] +[RealmRequired] +[Route("api/v1/clients")] +public sealed class ClientsController(IDispatcher dispatcher) : ControllerBase +{ + [HttpPost] + [Stability(Stability.Stable)] + [Authorize(Roles = Permissions.CreateClient)] + public async Task CreateClientAsync([FromBody] ClientCreationScheme request, CancellationToken cancellation) + { + var result = await dispatcher.DispatchAsync(request, 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.Status201Created) + }; + } +} From e2c7e8664202de08c803cfb0fb72bf69a66f5666 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 14 Apr 2026 20:31:09 -0300 Subject: [PATCH 040/117] feature(#20): it includes the client scheme class in the payloads namespace, containing properties for identification, client credentials, and collections for authorization flows and redirect URIs. --- .../Payloads/Client/ClientScheme.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientScheme.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientScheme.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientScheme.cs new file mode 100644 index 0000000..fca6f66 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientScheme.cs @@ -0,0 +1,13 @@ +namespace HttpsRichardy.Federation.Application.Payloads.Client; + +public sealed record ClientScheme +{ + public string Id { get; set; } = default!; + public string Name { get; set; } = default!; + + public string ClientId { get; set; } = default!; + public string ClientSecret { get; set; } = default!; + + public IEnumerable Flows { get; set; } = []; + public IEnumerable RedirectUris { get; set; } = []; +} From 88691fdc1587129ed00860bcb7a992952987e089 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 14 Apr 2026 20:31:54 -0300 Subject: [PATCH 041/117] feature(#20): this commit introduces the record class representing the client filtering parameters. --- .../Payloads/Client/ClientsFetchParameters.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientsFetchParameters.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientsFetchParameters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientsFetchParameters.cs new file mode 100644 index 0000000..6472ddb --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientsFetchParameters.cs @@ -0,0 +1,14 @@ +namespace HttpsRichardy.Federation.Application.Payloads.Client; + +public sealed record ClientsFetchParameters : + IDispatchable>> +{ + public string? Name { get; set; } + public string? ClientId { get; set; } + + public PaginationFilters? Pagination { get; set; } + public SortFilters? Sort { get; set; } + + public DateOnly? CreatedAfter { get; set; } + public DateOnly? CreatedBefore { get; set; } +} From e444a45589235eca3610f82c765d12cf356d242d Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 14 Apr 2026 20:41:17 -0300 Subject: [PATCH 042/117] feature(#20): this commit introduces a new method in the client mapper to map a domain entity to a read model. --- .../Mappers/ClientMapper.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/ClientMapper.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/ClientMapper.cs index 4622930..be5ebf1 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/ClientMapper.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/ClientMapper.cs @@ -11,4 +11,14 @@ public static class ClientMapper Flows = [.. client.Flows], RedirectUris = [.. client.RedirectUris.Select(uri => new RedirectUri(uri))] }; + + public static ClientScheme AsResponse(this Client client) => new() + { + Id = client.Id, + Name = client.Name, + ClientId = client.ClientId, + ClientSecret = client.Secret, + Flows = client.Flows, + RedirectUris = client.RedirectUris + }; } From e628a6177d4bb1e2a97f79efe72b10d9438ae66d Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 14 Apr 2026 20:41:43 -0300 Subject: [PATCH 043/117] feature(#20): this commit introduces a new handler for filtering and searching clients in the application. --- .../Handlers/Client/FetchClientsHandler.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/FetchClientsHandler.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/FetchClientsHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/FetchClientsHandler.cs new file mode 100644 index 0000000..ec503b4 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/FetchClientsHandler.cs @@ -0,0 +1,29 @@ +namespace HttpsRichardy.Federation.Application.Handlers.Client; + +public sealed class FetchClientsHandler(IClientCollection clientCollection) : + IDispatchHandler>> +{ + public async Task>> HandleAsync( + ClientsFetchParameters parameters, CancellationToken cancellation = default) + { + var filters = ClientFilters.WithSpecifications() + .WithName(parameters.Name) + .WithClientId(parameters.ClientId) + .WithSort(parameters.Sort) + .WithPagination(parameters.Pagination) + .Build(); + + var clients = await clientCollection.GetClientsAsync(filters, cancellation); + var totalClients = await clientCollection.CountClientsAsync(filters, cancellation); + + var pagination = new Pagination + { + Items = [.. clients.Select(client => ClientMapper.AsResponse(client))], + Total = (int)totalClients, + PageNumber = parameters.Pagination?.PageNumber ?? 1, + PageSize = parameters.Pagination?.PageSize ?? 20, + }; + + return Result>.Success(pagination); + } +} From 518420894184a7b3c32eb4e09d47baf6129f2ccb Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 14 Apr 2026 20:55:14 -0300 Subject: [PATCH 044/117] feature(#20): this commit introduces a new endpoint for obtaining clients, enabling filtering. --- .../Controllers/ClientsController.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs index ad17aa1..12b5543 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs @@ -5,6 +5,23 @@ [Route("api/v1/clients")] public sealed class ClientsController(IDispatcher dispatcher) : ControllerBase { + [HttpGet] + [Stability(Stability.Stable)] + [Authorize(Roles = Permissions.ViewClients)] + public async Task GetClientsAsync([FromQuery] ClientsFetchParameters request, CancellationToken cancellation) + { + var result = await dispatcher.DispatchAsync(request, 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 } when result.Data is not null => + StatusCode(StatusCodes.Status200OK, result.Data) + }; + } + [HttpPost] [Stability(Stability.Stable)] [Authorize(Roles = Permissions.CreateClient)] @@ -14,6 +31,7 @@ public async Task CreateClientAsync([FromBody] ClientCreationSche // 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.Status201Created) From 5591394a964095a607bfcfb575f46a1f588463ba Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 14 Apr 2026 21:05:44 -0300 Subject: [PATCH 045/117] feature(#20): this commit includes annotation in the clients controller class to apply custom API conventions. --- .../Controllers/ClientsController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs index 12b5543..d7b0cc8 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs @@ -1,6 +1,7 @@ namespace HttpsRichardy.Federation.WebApi.Controllers; [ApiController] +[ApiConventionType(typeof(ClientsConventions))] [RealmRequired] [Route("api/v1/clients")] public sealed class ClientsController(IDispatcher dispatcher) : ControllerBase From e236dc4c110571291c27309a5b670a3099052aa8 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 14 Apr 2026 21:06:54 -0300 Subject: [PATCH 046/117] feature(#20): this commit includes the static class `clients conventions` with methods that define naming conventions and response types for client endpoints, using convention attributes. --- .../Conventions/ClientsConventions.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs new file mode 100644 index 0000000..a18eb11 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs @@ -0,0 +1,14 @@ +#pragma warning disable IDE0060 + +namespace HttpsRichardy.Federation.WebApi.Conventions; + +public static class ClientsConventions +{ + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + [ProducesResponseType(typeof(Pagination), StatusCodes.Status200OK)] + public static void GetClientsAsync(ClientsFetchParameters request, CancellationToken cancellation) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + [ProducesResponseType(StatusCodes.Status201Created)] + public static void CreateClientAsync(ClientCreationScheme request, CancellationToken cancellation) { } +} From 36af1b169ee49c1c0c0538b4e09ced61206e52cc Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 14 Apr 2026 21:23:26 -0300 Subject: [PATCH 047/117] feature(#20): this commit includes the filter using `FilterDefinitions.MatchIfNotEmpty` to consider the client's ID field when building the filter pipeline, allowing for more precise searches by ID. --- .../Pipelines/ClientFiltersStage.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/ClientFiltersStage.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/ClientFiltersStage.cs index 0b22044..9c8a5d3 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/ClientFiltersStage.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/ClientFiltersStage.cs @@ -8,6 +8,7 @@ public static PipelineDefinition FilterClients(this Pipeli var realm = realmProvider.GetCurrentRealm(); var definitions = new List> { + FilterDefinitions.MatchIfNotEmpty(Documents.Client.Id, filters.Id), FilterDefinitions.MatchIfNotEmpty(Documents.Client.Name, filters.Name), FilterDefinitions.MatchIfNotEmpty(Documents.Client.ClientId, filters.ClientId), FilterDefinitions.MatchIfNotEmpty(Documents.Client.RealmId, realm?.Id), @@ -17,3 +18,4 @@ public static PipelineDefinition FilterClients(this Pipeli return pipeline.Match(Builders.Filter.And(definitions)); } } + From cbff9353b705e4957a63d53829c08ac14d5eac5a Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 14 Apr 2026 21:24:44 -0300 Subject: [PATCH 048/117] feature(#20): this commit introduces a handler to process the list of permissions assigned to a client, filtered by identifier. --- .../ListClientAssignedPermissionsHandler.cs | 20 +++++++++++++++++++ ...ListClientAssignedPermissionsParameters.cs | 7 +++++++ 2 files changed, 27 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ListClientAssignedPermissionsHandler.cs create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ListClientAssignedPermissionsParameters.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ListClientAssignedPermissionsHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ListClientAssignedPermissionsHandler.cs new file mode 100644 index 0000000..a97d9ca --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ListClientAssignedPermissionsHandler.cs @@ -0,0 +1,20 @@ +namespace HttpsRichardy.Federation.Application.Handlers.Client; + +public sealed class ListClientAssignedPermissionsHandler(IClientCollection collection) : + IDispatchHandler>> +{ + public async Task>> HandleAsync( + ListClientAssignedPermissionsParameters parameters, CancellationToken cancellation = default) + { + var filters = ClientFilters.WithSpecifications() + .WithIdentifier(parameters.Id) + .Build(); + + var clients = await collection.GetClientsAsync(filters, cancellation); + var client = clients.FirstOrDefault(); + + return client is not null + ? Result>.Success(PermissionMapper.AsResponse(client.Permissions)) + : Result>.Failure(ClientErrors.ClientDoesNotExist); + } +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ListClientAssignedPermissionsParameters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ListClientAssignedPermissionsParameters.cs new file mode 100644 index 0000000..74861a6 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ListClientAssignedPermissionsParameters.cs @@ -0,0 +1,7 @@ +namespace HttpsRichardy.Federation.Application.Payloads.Client; + +public sealed record ListClientAssignedPermissionsParameters : + IDispatchable>> +{ + public string? Id { get; init; } +} From 9e52e712ddd7cd93ab83539b2b196efbaa34f0ad Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 14 Apr 2026 21:26:03 -0300 Subject: [PATCH 049/117] feature(#20): this commit introduces the static readonly error 'client does not exist', with code "#ERROR-2D943" and description for cases where the client does not exist. --- .../Errors/ClientErrors.cs | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs new file mode 100644 index 0000000..ad7854a --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs @@ -0,0 +1,9 @@ +namespace HttpsRichardy.Federation.Domain.Errors; + +public static class ClientErrors +{ + public static readonly Error ClientDoesNotExist = new( + Code: "#ERROR-2D943", + Description: "The client with the specified ID does not exist." + ); +} From 8e2e179c5f76fec86ee49cc19c2658df2ba20ba0 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 14 Apr 2026 21:27:03 -0300 Subject: [PATCH 050/117] feature(#20): includes the GET route /{id}/permissions in the clients controller to list permissions assigned to a client. --- .../Controllers/ClientsController.cs | 17 +++++++++++++++++ .../Conventions/ClientsConventions.cs | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs index d7b0cc8..121de1f 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs @@ -38,4 +38,21 @@ public async Task CreateClientAsync([FromBody] ClientCreationSche { IsSuccess: true } => StatusCode(StatusCodes.Status201Created) }; } + + [HttpGet("{id}/permissions")] + [Stability(Stability.Stable)] + [Authorize(Roles = Permissions.ViewPermissions)] + public async Task GetClientPermissionsAsync([FromRoute] string id, [FromQuery] ListClientAssignedPermissionsParameters request, CancellationToken cancellation) + { + var result = await dispatcher.DispatchAsync(request with { Id = id }, cancellation); + + return result switch + { + { IsSuccess: true } when result.Data is not null => + StatusCode(StatusCodes.Status200OK, result.Data), + + { IsFailure: true } when result.Error == ClientErrors.ClientDoesNotExist => + StatusCode(StatusCodes.Status404NotFound, result.Error), + }; + } } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs index a18eb11..b46c64a 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs @@ -11,4 +11,9 @@ public static void GetClientsAsync(ClientsFetchParameters request, CancellationT [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] [ProducesResponseType(StatusCodes.Status201Created)] public static void CreateClientAsync(ClientCreationScheme request, CancellationToken cancellation) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + [ProducesResponseType(typeof(IReadOnlyCollection), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] + public static void GetClientPermissionsAsync(string id, ListClientAssignedPermissionsParameters request, CancellationToken cancellation) { } } From 937bce00744722f5f1d36fc053a91de2f2171916 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 14 Apr 2026 21:38:44 -0300 Subject: [PATCH 051/117] feature(#20): this commit introduces a scheme and its validator. --- .../Payloads/Client/ClientUpdateScheme.cs | 11 +++++++ .../Client/ClientUpdateSchemeValidator.cs | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientUpdateScheme.cs create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Client/ClientUpdateSchemeValidator.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientUpdateScheme.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientUpdateScheme.cs new file mode 100644 index 0000000..7589a56 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientUpdateScheme.cs @@ -0,0 +1,11 @@ +namespace HttpsRichardy.Federation.Application.Payloads.Client; + +public sealed record ClientUpdateScheme : IDispatchable> +{ + [JsonIgnore] + public string Id { get; init; } = default!; + public string Name { get; init; } = default!; + + public IEnumerable Flows { get; init; } = []; + public IEnumerable RedirectUris { get; init; } = []; +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Client/ClientUpdateSchemeValidator.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Client/ClientUpdateSchemeValidator.cs new file mode 100644 index 0000000..bec4808 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Client/ClientUpdateSchemeValidator.cs @@ -0,0 +1,31 @@ +namespace HttpsRichardy.Federation.Application.Validators.Client; + +public sealed class ClientUpdateSchemeValidator : AbstractValidator +{ + public ClientUpdateSchemeValidator() + { + RuleFor(client => client.Name) + .NotEmpty() + .WithMessage("client name must not be empty.") + .MaximumLength(100) + .WithMessage("client name must not exceed 100 characters.") + .MinimumLength(3) + .WithMessage("client name must be at least 3 characters long."); + + RuleFor(client => client.Flows) + .NotEmpty() + .WithMessage("client must have at least one flow."); + + RuleForEach(client => client.Flows) + .IsInEnum() + .WithMessage("client flow must be a valid grant type."); + + When(client => client.RedirectUris is not null && client.RedirectUris.Any(), () => + { + RuleForEach(client => client.RedirectUris) + .Must(uri => !string.IsNullOrWhiteSpace(uri) && Uri.TryCreate(uri, UriKind.Absolute, out var parsed) && + (parsed.Scheme == Uri.UriSchemeHttp || parsed.Scheme == Uri.UriSchemeHttps)) + .WithMessage("redirect uri must be a valid url."); + }); + } +} From c4db24078ef989f53475298a1e87c79b6ba0deb5 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 14 Apr 2026 21:40:01 -0300 Subject: [PATCH 052/117] feature(#20): implements a handler to update clients, including searching by ID, validating existence, mapping the data, and persisting the update in the collection. --- .../Handlers/Client/ClientUpdateHandler.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientUpdateHandler.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientUpdateHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientUpdateHandler.cs new file mode 100644 index 0000000..2cf8bff --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientUpdateHandler.cs @@ -0,0 +1,27 @@ +namespace HttpsRichardy.Federation.Application.Handlers.Client; + +public sealed class ClientUpdateHandler(IClientCollection collection) : + IDispatchHandler> +{ + public async Task> HandleAsync( + ClientUpdateScheme parameters, CancellationToken cancellation = default) + { + var filters = ClientFilters.WithSpecifications() + .WithIdentifier(parameters.Id) + .Build(); + + var clients = await collection.GetClientsAsync(filters, cancellation: cancellation); + var client = clients.FirstOrDefault(); + + if (client is null) + { + return Result.Failure(ClientErrors.ClientDoesNotExist); + } + + client = ClientMapper.AsClient(parameters, client); + + var updatedClient = await collection.UpdateAsync(client, cancellation: cancellation); + + return Result.Success(ClientMapper.AsResponse(updatedClient)); + } +} From 6a19ee44a0138da64e6cd307bb27910df178fa98 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 14 Apr 2026 21:40:49 -0300 Subject: [PATCH 053/117] feature(#20): this commit introduces the mapper for mapping an update model to a domain client. --- .../Mappers/ClientMapper.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/ClientMapper.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/ClientMapper.cs index be5ebf1..b6839f9 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/ClientMapper.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/ClientMapper.cs @@ -12,6 +12,15 @@ public static class ClientMapper RedirectUris = [.. client.RedirectUris.Select(uri => new RedirectUri(uri))] }; + public static Client AsClient(ClientUpdateScheme payload, Client client) + { + client.Name = payload.Name; + client.Flows = [.. payload.Flows]; + client.RedirectUris = [.. payload.RedirectUris.Select(uri => new RedirectUri(uri))]; + + return client; + } + public static ClientScheme AsResponse(this Client client) => new() { Id = client.Id, From 354d0e2b0ffdffcbc01214568e1875bb3685e871 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Tue, 14 Apr 2026 21:41:35 -0300 Subject: [PATCH 054/117] feature(#20): this commit introduces the PUT endpoint for updating clients. --- .../Controllers/ClientsController.cs | 18 ++++++++++++++++++ .../Conventions/ClientsConventions.cs | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs index 121de1f..8f622d7 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs @@ -39,6 +39,23 @@ public async Task CreateClientAsync([FromBody] ClientCreationSche }; } + [HttpPut("{id}")] + [Stability(Stability.Stable)] + [Authorize(Roles = Permissions.EditClient)] + public async Task UpdateClientAsync([FromRoute] string id, [FromBody] ClientUpdateScheme request, CancellationToken cancellation) + { + var result = await dispatcher.DispatchAsync(request with { Id = id }, cancellation); + + return result switch + { + { IsSuccess: true } when result.Data is not null => + StatusCode(StatusCodes.Status200OK, result.Data), + + { IsFailure: true } when result.Error == ClientErrors.ClientDoesNotExist => + StatusCode(StatusCodes.Status404NotFound, result.Error), + }; + } + [HttpGet("{id}/permissions")] [Stability(Stability.Stable)] [Authorize(Roles = Permissions.ViewPermissions)] @@ -48,6 +65,7 @@ public async Task GetClientPermissionsAsync([FromRoute] string id return result switch { + { IsSuccess: true } when result.Data is not null => StatusCode(StatusCodes.Status200OK, result.Data), diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs index b46c64a..5ea7127 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs @@ -12,6 +12,11 @@ public static void GetClientsAsync(ClientsFetchParameters request, CancellationT [ProducesResponseType(StatusCodes.Status201Created)] public static void CreateClientAsync(ClientCreationScheme request, CancellationToken cancellation) { } + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + [ProducesResponseType(typeof(ClientScheme), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] + public static void UpdateClientAsync(string id, ClientUpdateScheme request, CancellationToken cancellation) { } + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] [ProducesResponseType(typeof(IReadOnlyCollection), StatusCodes.Status200OK)] [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] From cc837c678d8666cffb6a42861c179c0a04ecca2f Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 14:27:49 -0300 Subject: [PATCH 055/117] feature(#20): this commit introduces scheme and its validator for managing client permissions --- .../Client/AssignClientPermissionScheme.cs | 9 +++++++++ .../Client/AssignClientPermissionValidator.cs | 15 +++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/AssignClientPermissionScheme.cs create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Client/AssignClientPermissionValidator.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/AssignClientPermissionScheme.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/AssignClientPermissionScheme.cs new file mode 100644 index 0000000..53b9392 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/AssignClientPermissionScheme.cs @@ -0,0 +1,9 @@ +namespace HttpsRichardy.Federation.Application.Payloads.Client; + +public sealed record AssignClientPermissionScheme : + IDispatchable>> +{ + [JsonIgnore] + public string Id { get; init; } = default!; + public string PermissionName { get; init; } = default!; +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Client/AssignClientPermissionValidator.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Client/AssignClientPermissionValidator.cs new file mode 100644 index 0000000..fb78adf --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Client/AssignClientPermissionValidator.cs @@ -0,0 +1,15 @@ +namespace HttpsRichardy.Federation.Application.Validators.Client; + +public sealed class AssignClientPermissionValidator : AbstractValidator +{ + public AssignClientPermissionValidator() + { + RuleFor(request => request.PermissionName) + .NotEmpty() + .WithMessage("permission name must not be empty.") + .MinimumLength(3) + .WithMessage("permission name must be at least 3 characters long.") + .MaximumLength(200) + .WithMessage("permission name must be at most 200 characters long."); + } +} From 5cd2bd8ed38fefd31cb8008eb64d449c24e39deb Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 14:28:10 -0300 Subject: [PATCH 056/117] feature(#20): implement client handler for managing client permissions --- .../Client/AssignPermissionClientHandler.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/AssignPermissionClientHandler.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/AssignPermissionClientHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/AssignPermissionClientHandler.cs new file mode 100644 index 0000000..6ad63da --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/AssignPermissionClientHandler.cs @@ -0,0 +1,44 @@ +namespace HttpsRichardy.Federation.Application.Handlers.Client; + +public sealed class AssignPermissionClientHandler(IClientCollection clientCollection, IPermissionCollection permissionCollection) : + IDispatchHandler>> +{ + public async Task>> HandleAsync( + AssignClientPermissionScheme parameters, CancellationToken cancellation = default) + { + var clientFilters = ClientFilters.WithSpecifications() + .WithIdentifier(parameters.Id) + .Build(); + + var permissionFilters = PermissionFilters.WithSpecifications() + .WithName(parameters.PermissionName.ToLower()) + .Build(); + + var clients = await clientCollection.GetClientsAsync(clientFilters, cancellation: cancellation); + var client = clients.FirstOrDefault(); + + if (client is null) + { + return Result>.Failure(ClientErrors.ClientDoesNotExist); + } + + var permissions = await permissionCollection.GetPermissionsAsync(permissionFilters, cancellation: cancellation); + var existingPermission = permissions.FirstOrDefault(); + + if (existingPermission is null) + { + return Result>.Failure(PermissionErrors.PermissionDoesNotExist); + } + + if (client.Permissions.Any(permission => permission.Name == existingPermission.Name)) + { + return Result>.Failure(ClientErrors.ClientAlreadyHasPermission); + } + + client.Permissions.Add(existingPermission); + + await clientCollection.UpdateAsync(client, cancellation: cancellation); + + return Result>.Success(PermissionMapper.AsResponse(client.Permissions)); + } +} From 3ee4be3b94186624722340d16d708c1ff85b68ed Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 14:28:26 -0300 Subject: [PATCH 057/117] feature(#20): this commit introduces error for client already having a specified permission --- .../HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs index ad7854a..6d9a27a 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs @@ -2,6 +2,11 @@ namespace HttpsRichardy.Federation.Domain.Errors; public static class ClientErrors { + public static readonly Error ClientAlreadyHasPermission = new( + Code: "#ERROR-8D71B", + Description: "The client already has the specified permission assigned." + ); + public static readonly Error ClientDoesNotExist = new( Code: "#ERROR-2D943", Description: "The client with the specified ID does not exist." From f808fcf77fb331786dcf7d65db005dc8365bf6f3 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 14:28:38 -0300 Subject: [PATCH 058/117] feature(#20): this commit introduces validator for assigning client permissions --- .../Extensions/ValidationExtension.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ValidationExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ValidationExtension.cs index 7049c70..0afd6cf 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ValidationExtension.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ValidationExtension.cs @@ -21,6 +21,7 @@ public static void AddValidators(this IServiceCollection services) services.AddTransient, RealmUpdateValidator>(); services.AddTransient, ClientCreationSchemeValidator>(); + services.AddTransient, AssignClientPermissionValidator>(); services.AddTransient, AssignUserPermissionValidator>(); services.AddTransient, AssignRealmPermissionValidator>(); From 26765bb139ee6706f9621ed24da2ab0d62f92523 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 14:31:38 -0300 Subject: [PATCH 059/117] feature(#20): this commit introduces endpoint and conventions for assigning client permissions --- .../Controllers/ClientsController.cs | 23 +++++++++++++++++++ .../Conventions/ClientsConventions.cs | 6 +++++ 2 files changed, 29 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs index 8f622d7..a2f9c7c 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs @@ -73,4 +73,27 @@ public async Task GetClientPermissionsAsync([FromRoute] string id StatusCode(StatusCodes.Status404NotFound, result.Error), }; } + + [HttpPost("{id}/permissions")] + [Stability(Stability.Stable)] + [Authorize(Roles = Permissions.AssignPermissions)] + public async Task AssignPermissionAsync([FromRoute] string id, [FromBody] AssignClientPermissionScheme request, CancellationToken cancellation) + { + var result = await dispatcher.DispatchAsync(request with { Id = id }, cancellation); + + return result switch + { + { IsSuccess: true } when result.Data is not null => + StatusCode(StatusCodes.Status200OK, result.Data), + + { IsFailure: true } when result.Error == ClientErrors.ClientDoesNotExist => + StatusCode(StatusCodes.Status404NotFound, result.Error), + + { IsFailure: true } when result.Error == PermissionErrors.PermissionDoesNotExist => + StatusCode(StatusCodes.Status404NotFound, result.Error), + + { IsFailure: true } when result.Error == ClientErrors.ClientAlreadyHasPermission => + StatusCode(StatusCodes.Status409Conflict, result.Error), + }; + } } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs index 5ea7127..fc949e1 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs @@ -21,4 +21,10 @@ public static void UpdateClientAsync(string id, ClientUpdateScheme request, Canc [ProducesResponseType(typeof(IReadOnlyCollection), StatusCodes.Status200OK)] [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] public static void GetClientPermissionsAsync(string id, ListClientAssignedPermissionsParameters request, CancellationToken cancellation) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + [ProducesResponseType(typeof(IReadOnlyCollection), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(Error), StatusCodes.Status409Conflict)] + public static void AssignPermissionAsync(string id, AssignClientPermissionScheme request, CancellationToken cancellation) { } } From 9d824a80b040a6bac09244577cd704fa73610b98 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 14:59:44 -0300 Subject: [PATCH 060/117] feature(#20): this commit introduces endpoint and conventions for revoking client permissions --- .../Controllers/ClientsController.cs | 24 +++++++++++++++++++ .../Conventions/ClientsConventions.cs | 6 +++++ 2 files changed, 30 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs index a2f9c7c..30fbeb6 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs @@ -96,4 +96,28 @@ public async Task AssignPermissionAsync([FromRoute] string id, [F StatusCode(StatusCodes.Status409Conflict, result.Error), }; } + + [HttpDelete("{id}/permissions/{permissionId}")] + [Stability(Stability.Stable)] + [Authorize(Roles = Permissions.RevokePermissions)] + public async Task RevokePermissionAsync([FromRoute] string id, [FromRoute] string permissionId, CancellationToken cancellation) + { + var request = new RevokeClientPermissionScheme { Id = id, PermissionId = permissionId }; + var result = await dispatcher.DispatchAsync(request, cancellation); + + return result switch + { + { IsSuccess: true } => + StatusCode(StatusCodes.Status204NoContent), + + { IsFailure: true } when result.Error == ClientErrors.ClientDoesNotExist => + StatusCode(StatusCodes.Status404NotFound, result.Error), + + { IsFailure: true } when result.Error == PermissionErrors.PermissionDoesNotExist => + StatusCode(StatusCodes.Status404NotFound, result.Error), + + { IsFailure: true } when result.Error == ClientErrors.PermissionNotAssigned => + StatusCode(StatusCodes.Status409Conflict, result.Error) + }; + } } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs index fc949e1..8061dad 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs @@ -27,4 +27,10 @@ public static void GetClientPermissionsAsync(string id, ListClientAssignedPermis [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(Error), StatusCodes.Status409Conflict)] public static void AssignPermissionAsync(string id, AssignClientPermissionScheme request, CancellationToken cancellation) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(Error), StatusCodes.Status409Conflict)] + public static void RevokePermissionAsync(string id, string permissionId, CancellationToken cancellation) { } } From a951bf579e98c8f0aefca0b5fac00df4ed38e9d3 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 15:00:05 -0300 Subject: [PATCH 061/117] feature(#20): this commit introduces handler and scheme for revoking client permissions --- .../Client/RevokeClientPermissionHandler.cs | 44 +++++++++++++++++++ .../Client/RevokeClientPermissionScheme.cs | 7 +++ 2 files changed, 51 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/RevokeClientPermissionHandler.cs create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/RevokeClientPermissionScheme.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/RevokeClientPermissionHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/RevokeClientPermissionHandler.cs new file mode 100644 index 0000000..e1df204 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/RevokeClientPermissionHandler.cs @@ -0,0 +1,44 @@ +namespace HttpsRichardy.Federation.Application.Handlers.Client; + +public sealed class RevokeClientPermissionHandler(IClientCollection clientCollection, IPermissionCollection permissionCollection) : + IDispatchHandler +{ + public async Task HandleAsync(RevokeClientPermissionScheme parameters, CancellationToken cancellation = default) + { + var permissionFilters = PermissionFilters.WithSpecifications() + .WithIdentifier(parameters.PermissionId) + .Build(); + + var clientFilters = ClientFilters.WithSpecifications() + .WithIdentifier(parameters.Id) + .Build(); + + var clients = await clientCollection.GetClientsAsync(clientFilters, cancellation); + var client = clients.FirstOrDefault(); + + var permissions = await permissionCollection.GetPermissionsAsync(permissionFilters, cancellation); + var permission = permissions.FirstOrDefault(); + + if (client is null) + { + return Result.Failure(ClientErrors.ClientDoesNotExist); + } + + if (permission is null) + { + return Result.Failure(PermissionErrors.PermissionDoesNotExist); + } + + var permissionToRemove = client.Permissions.FirstOrDefault(where => where.Id == permission.Id); + if (permissionToRemove is null) + { + return Result.Failure(ClientErrors.PermissionNotAssigned); + } + + client.Permissions.Remove(permissionToRemove); + + await clientCollection.UpdateAsync(client, cancellation); + + return Result.Success(); + } +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/RevokeClientPermissionScheme.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/RevokeClientPermissionScheme.cs new file mode 100644 index 0000000..e0bc549 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/RevokeClientPermissionScheme.cs @@ -0,0 +1,7 @@ +namespace HttpsRichardy.Federation.Application.Payloads.Client; + +public sealed record RevokeClientPermissionScheme : IDispatchable +{ + public string PermissionId { get; init; } = default!; + public string Id { get; init; } = default!; +} From 99611189d9d14d51a96cda020b2eb257e4369c59 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 15:00:57 -0300 Subject: [PATCH 062/117] feature(#20): this commit error for permission not assigned to client --- .../HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs index 6d9a27a..894b6ac 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs @@ -7,6 +7,11 @@ public static class ClientErrors Description: "The client already has the specified permission assigned." ); + public static readonly Error PermissionNotAssigned = new( + Code: "#ERROR-C2FB0", + Description: "The client does not have the specified permission assigned." + ); + public static readonly Error ClientDoesNotExist = new( Code: "#ERROR-2D943", Description: "The client with the specified ID does not exist." From dd7c8279d1eff942026eb1a1638b3538fb851979 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 15:13:27 -0300 Subject: [PATCH 063/117] feature(#20): this commit introduces endpoint and conventions for deleting clients --- .../Controllers/ClientsController.cs | 17 +++++++++++++++++ .../Conventions/ClientsConventions.cs | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs index 30fbeb6..016ec75 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs @@ -56,6 +56,23 @@ public async Task UpdateClientAsync([FromRoute] string id, [FromB }; } + [HttpDelete("{id}")] + [Stability(Stability.Stable)] + [Authorize(Roles = Permissions.DeleteClient)] + public async Task DeleteClientAsync([FromRoute] string id, [FromQuery] ClientDeletionScheme request, CancellationToken cancellation) + { + var result = await dispatcher.DispatchAsync(request with { Id = id }, cancellation); + + return result switch + { + { IsSuccess: true } => + StatusCode(StatusCodes.Status204NoContent), + + { IsFailure: true } when result.Error == ClientErrors.ClientDoesNotExist => + StatusCode(StatusCodes.Status404NotFound, result.Error), + }; + } + [HttpGet("{id}/permissions")] [Stability(Stability.Stable)] [Authorize(Roles = Permissions.ViewPermissions)] diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs index 8061dad..4cbe716 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs @@ -17,6 +17,11 @@ public static void CreateClientAsync(ClientCreationScheme request, CancellationT [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] public static void UpdateClientAsync(string id, ClientUpdateScheme request, CancellationToken cancellation) { } + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] + public static void DeleteClientAsync(string id, CancellationToken cancellation) { } + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] [ProducesResponseType(typeof(IReadOnlyCollection), StatusCodes.Status200OK)] [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] From a892d6c8c360f2f20878b1b4c2bd653866fec266 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 15:13:51 -0300 Subject: [PATCH 064/117] feature(#20): this commit introduces handler and scheme for client deletion --- .../Handlers/Client/ClientDeletionHandler.cs | 23 +++++++++++++++++++ .../Payloads/Client/ClientDeletionScheme.cs | 6 +++++ 2 files changed, 29 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientDeletionHandler.cs create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientDeletionScheme.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientDeletionHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientDeletionHandler.cs new file mode 100644 index 0000000..6c6626d --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientDeletionHandler.cs @@ -0,0 +1,23 @@ +namespace HttpsRichardy.Federation.Application.Handlers.Client; + +public sealed class ClientDeletionHandler(IClientCollection collection) : IDispatchHandler +{ + public async Task HandleAsync(ClientDeletionScheme parameters, CancellationToken cancellation = default) + { + var filters = ClientFilters.WithSpecifications() + .WithIdentifier(parameters.Id) + .Build(); + + var clients = await collection.GetClientsAsync(filters, cancellation: cancellation); + var client = clients.FirstOrDefault(); + + if (client is null) + { + return Result.Failure(ClientErrors.ClientDoesNotExist); + } + + await collection.DeleteAsync(client, cancellation: cancellation); + + return Result.Success(); + } +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientDeletionScheme.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientDeletionScheme.cs new file mode 100644 index 0000000..ccc205f --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientDeletionScheme.cs @@ -0,0 +1,6 @@ +namespace HttpsRichardy.Federation.Application.Payloads.Client; + +public sealed record ClientDeletionScheme : IDispatchable +{ + public string Id { get; init; } = default!; +} From 7c2b5d00e7defa69f534e1d302fa5bbd9e22a93f Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 15:24:29 -0300 Subject: [PATCH 065/117] feature(#20): update client credentials generation to use clientName instead of realmName --- .../Services/IClientCredentialsGenerator.cs | 2 +- .../Security/ClientCredentialsGenerator.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/IClientCredentialsGenerator.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/IClientCredentialsGenerator.cs index 0d7d0ea..78536b6 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/IClientCredentialsGenerator.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/IClientCredentialsGenerator.cs @@ -2,5 +2,5 @@ namespace HttpsRichardy.Federation.Application.Services; public interface IClientCredentialsGenerator { - public Task GenerateAsync(string realmName, CancellationToken cancellation = default); + public Task GenerateAsync(string clientName, CancellationToken cancellation = default); } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/ClientCredentialsGenerator.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/ClientCredentialsGenerator.cs index 838ae18..ff6a76c 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/ClientCredentialsGenerator.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/ClientCredentialsGenerator.cs @@ -2,14 +2,14 @@ namespace HttpsRichardy.Federation.Infrastructure.Security; public sealed class ClientCredentialsGenerator(IPasswordHasher passwordHasher) : IClientCredentialsGenerator { - public async Task GenerateAsync(string realmName, CancellationToken cancellation = default) + public async Task GenerateAsync(string clientName, CancellationToken cancellation = default) { var bytes = new byte[32]; RandomNumberGenerator.Fill(bytes); var clientId = Convert.ToHexString(bytes).ToLowerInvariant(); - var clientSecret = await passwordHasher.HashPasswordAsync(clientId + realmName); + var clientSecret = await passwordHasher.HashPasswordAsync(clientId + clientName); return new ClientCredentials { @@ -17,4 +17,4 @@ public async Task GenerateAsync(string realmName, Cancellatio ClientSecret = clientSecret }; } -} \ No newline at end of file +} From 3bee2eb036a765c43e3f2679d59a550c5696856a Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 15:28:51 -0300 Subject: [PATCH 066/117] feature(#20): this commit introduces error for client already exists --- .../HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs index 894b6ac..d3c2c9f 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs @@ -2,6 +2,11 @@ namespace HttpsRichardy.Federation.Domain.Errors; public static class ClientErrors { + public static readonly Error ClientAlreadyExists = new( + Code: "#ERROR-A7C31", + Description: "A client with the same name already exists." + ); + public static readonly Error ClientAlreadyHasPermission = new( Code: "#ERROR-8D71B", Description: "The client already has the specified permission assigned." From 62a86af82f8c3243e36c613b30a2caaee0404cb1 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 15:29:35 -0300 Subject: [PATCH 067/117] feature(#20): this commit introduces conflict response for existing client during creation --- .../Controllers/ClientsController.cs | 6 +++++- .../Conventions/ClientsConventions.cs | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs index 016ec75..df0d31b 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs @@ -35,7 +35,11 @@ public async Task CreateClientAsync([FromBody] ClientCreationSche return result switch { - { IsSuccess: true } => StatusCode(StatusCodes.Status201Created) + { IsSuccess: true } => + StatusCode(StatusCodes.Status201Created), + + { IsFailure: true } when result.Error == ClientErrors.ClientAlreadyExists => + StatusCode(StatusCodes.Status409Conflict, result.Error), }; } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs index 4cbe716..fffc3dc 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs @@ -10,6 +10,7 @@ public static void GetClientsAsync(ClientsFetchParameters request, CancellationT [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(typeof(Error), StatusCodes.Status409Conflict)] public static void CreateClientAsync(ClientCreationScheme request, CancellationToken cancellation) { } [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] From edbf843650871bb7ffa20b586251cfb89a971a09 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 15:29:53 -0300 Subject: [PATCH 068/117] feature(#20): this commit introduces client existence check during creation to prevent duplicates --- .../Handlers/Client/ClientCreationHandler.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientCreationHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientCreationHandler.cs index 13f821a..ecab996 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientCreationHandler.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientCreationHandler.cs @@ -5,6 +5,18 @@ public sealed class ClientCreationHandler(IClientCredentialsGenerator credential { public async Task HandleAsync(ClientCreationScheme parameters, CancellationToken cancellation = default) { + var filters = ClientFilters.WithSpecifications() + .WithName(parameters.Name) + .Build(); + + var clients = await clientCollection.GetClientsAsync(filters, cancellation: cancellation); + var existingClient = clients.FirstOrDefault(); + + if (existingClient is not null) + { + return Result.Failure(ClientErrors.ClientAlreadyExists); + } + var realm = realmProvider.GetCurrentRealm(); var credentials = await credentialsGenerator.GenerateAsync(parameters.Name, cancellation); From 03e51ac9978b17686bc5c72f17e6f2433e45c3f2 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 15:33:05 -0300 Subject: [PATCH 069/117] feature(#20): this commit introduces conflict response for existing client during creation and update conventions --- .../Controllers/ClientsController.cs | 3 +++ .../Conventions/ClientsConventions.cs | 1 + 2 files changed, 4 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs index df0d31b..9445fb8 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs @@ -57,6 +57,9 @@ public async Task UpdateClientAsync([FromRoute] string id, [FromB { IsFailure: true } when result.Error == ClientErrors.ClientDoesNotExist => StatusCode(StatusCodes.Status404NotFound, result.Error), + + { IsFailure: true } when result.Error == ClientErrors.ClientAlreadyExists => + StatusCode(StatusCodes.Status409Conflict, result.Error), }; } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs index fffc3dc..147ad9f 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs @@ -16,6 +16,7 @@ public static void CreateClientAsync(ClientCreationScheme request, CancellationT [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] [ProducesResponseType(typeof(ClientScheme), StatusCodes.Status200OK)] [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(Error), StatusCodes.Status409Conflict)] public static void UpdateClientAsync(string id, ClientUpdateScheme request, CancellationToken cancellation) { } [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] From 2a771328c2ddef4bd6c3ed7deca147d0660cc23d Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 15:33:50 -0300 Subject: [PATCH 070/117] feature(#20): this commit introduces client name check during update to prevent duplicates --- .../Handlers/Client/ClientUpdateHandler.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientUpdateHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientUpdateHandler.cs index 2cf8bff..81a9a9e 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientUpdateHandler.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientUpdateHandler.cs @@ -18,6 +18,18 @@ public async Task> HandleAsync( return Result.Failure(ClientErrors.ClientDoesNotExist); } + var nameFilter = ClientFilters.WithSpecifications() + .WithName(parameters.Name) + .Build(); + + var clientsWithSameName = await collection.GetClientsAsync(nameFilter, cancellation: cancellation); + var existingClient = clientsWithSameName.FirstOrDefault(existing => existing.Id != parameters.Id); + + if (existingClient is not null) + { + return Result.Failure(ClientErrors.ClientAlreadyExists); + } + client = ClientMapper.AsClient(parameters, client); var updatedClient = await collection.UpdateAsync(client, cancellation: cancellation); From cf6887598fa6c28289c4a05fdaf79fdf06fca511 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 15:50:13 -0300 Subject: [PATCH 071/117] refactor(#20): this commit removes "with realm id" method and update generate access token async to use realm from realmProvider --- .../Security/ClaimsBuilder.cs | 6 ------ .../Security/JwtSecurityTokenService.cs | 6 ++++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/ClaimsBuilder.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/ClaimsBuilder.cs index 356c9db..3273d4c 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/ClaimsBuilder.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/ClaimsBuilder.cs @@ -23,12 +23,6 @@ public ClaimsBuilder WithClientId(string clientId) return this; } - public ClaimsBuilder WithRealmId(string realmId) - { - _claims.Add(new Claim(IdentityClaimNames.RealmId, realmId)); - return this; - } - public ClaimsBuilder WithRealmName(string realmName) { _claims.Add(new Claim(IdentityClaimNames.Realm, realmName)); diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs index a7b6106..2916c55 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs @@ -68,9 +68,11 @@ public async Task> GenerateAccessTokenAsync(User user, Can public async Task> GenerateAccessTokenAsync(Client client, CancellationToken cancellation = default) { var tokenHandler = new JwtSecurityTokenHandler(); + var realm = realmProvider.GetCurrentRealm(); + var claims = new ClaimsBuilder() - .WithRealmId(client.RealmId) - .WithRealmName(client.Name) + .WithRealmName(realm.Name) + .WithClientId(client.Id) .WithPermissions(client.Permissions) .Build(); From 32a852adbf4d7fa0339e3d53d23d80bd6f7a805f Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 18:49:13 -0300 Subject: [PATCH 072/117] feature(#20): this commit updates client credentials grant handler to include password verification and correct client ID filter --- .../Handlers/Authorization/ClientCredentialsGrantHandler.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/ClientCredentialsGrantHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/ClientCredentialsGrantHandler.cs index 345132c..3c092ad 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/ClientCredentialsGrantHandler.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/ClientCredentialsGrantHandler.cs @@ -1,6 +1,6 @@ namespace HttpsRichardy.Federation.Application.Handlers.Authorization; -public sealed class ClientCredentialsGrantHandler(IClientCollection clientCollection, ISecurityTokenService tokenService) : +public sealed class ClientCredentialsGrantHandler(IClientCollection clientCollection, IPasswordHasher passwordHasher, ISecurityTokenService tokenService) : IAuthorizationFlowHandler { public Grant Grant => Grant.ClientCredentials; @@ -8,7 +8,7 @@ public sealed class ClientCredentialsGrantHandler(IClientCollection clientCollec public async Task> HandleAsync(ClientAuthenticationCredentials parameters, CancellationToken cancellation = default) { var filters = ClientFilters.WithSpecifications() - .WithName(parameters.ClientId) + .WithClientId(parameters.ClientId) .Build(); var clients = await clientCollection.GetClientsAsync(filters, cancellation: cancellation); @@ -19,7 +19,7 @@ public async Task> HandleAsync(ClientAuthenti return Result.Failure(AuthenticationErrors.ClientNotFound); } - if (parameters.ClientSecret != client.Secret) + if (!await passwordHasher.VerifyPasswordAsync(parameters.ClientId + client.Name, client.Secret)) { return Result.Failure(AuthenticationErrors.InvalidClientCredentials); } From 2f6701323c1526ebb0e281d3332f12f0cd575173 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 18:50:33 -0300 Subject: [PATCH 073/117] feature(#20): this commit update client credentials generation to use 'admin' and adjust default client name accordingly --- .../Extensions/BootstrapperExtension.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs index 3a3c846..3cb6eb3 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs @@ -17,10 +17,10 @@ public static async Task UseBootstrapperAsync(this IApplicationBuilder builder) var passwordHasher = scope.ServiceProvider.GetRequiredService(); var settings = scope.ServiceProvider.GetRequiredService(); - var clientCredentials = await credentialsGenerator.GenerateAsync("root", cancellation: default); + var clientCredentials = await credentialsGenerator.GenerateAsync("admin", cancellation: default); var defaultRealm = new Realm { Name = "master" }; - var defaultClient = new Client { Name = "root", Flows = [Grant.ClientCredentials] }; + var defaultClient = new Client { Name = "admin", Flows = [Grant.ClientCredentials] }; var realmFilters = RealmFilters.WithSpecifications() .WithName("master") @@ -41,7 +41,7 @@ public static async Task UseBootstrapperAsync(this IApplicationBuilder builder) RealmId = defaultRealm.Id })]; - defaultClient.Secret = await passwordHasher.HashPasswordAsync(clientCredentials.ClientId + defaultRealm.Name); + defaultClient.Secret = await passwordHasher.HashPasswordAsync(clientCredentials.ClientId + defaultClient.Name); defaultClient.Permissions = [.. defaultRealm.Permissions]; defaultRealm.Clients = [defaultClient]; From ba2f910cd3e4ff3f765f1b00f2de66e5d147fa93 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Thu, 16 Apr 2026 19:20:34 -0300 Subject: [PATCH 074/117] feature(#20): this commit introduces missing global using directives for client and concepts namespaces --- Applications/Backend/Tests/Usings.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Applications/Backend/Tests/Usings.cs b/Applications/Backend/Tests/Usings.cs index 37313aa..4353b94 100644 --- a/Applications/Backend/Tests/Usings.cs +++ b/Applications/Backend/Tests/Usings.cs @@ -17,6 +17,7 @@ global using HttpsRichardy.Federation.Domain.Aggregates; global using HttpsRichardy.Federation.Domain.Filtering; global using HttpsRichardy.Federation.Domain.Collections; +global using HttpsRichardy.Federation.Domain.Concepts; global using HttpsRichardy.Federation.Common.Constants; global using HttpsRichardy.Federation.Application.Services; @@ -29,6 +30,7 @@ global using HttpsRichardy.Federation.Application.Payloads.Group; global using HttpsRichardy.Federation.Application.Payloads.Common; global using HttpsRichardy.Federation.Application.Payloads.Connect; +global using HttpsRichardy.Federation.Application.Payloads.Client; global using HttpsRichardy.Federation.Infrastructure.Persistence; global using HttpsRichardy.Federation.Infrastructure.Security; From b71a1a45fab65379fdbdcd04ada0a0c5b148786c Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 17 Apr 2026 15:37:07 -0300 Subject: [PATCH 075/117] feature(#20): this commit fixes hash secret comparasion --- .../Handlers/Authorization/ClientCredentialsGrantHandler.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/ClientCredentialsGrantHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/ClientCredentialsGrantHandler.cs index 3c092ad..40a110a 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/ClientCredentialsGrantHandler.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Authorization/ClientCredentialsGrantHandler.cs @@ -1,6 +1,6 @@ namespace HttpsRichardy.Federation.Application.Handlers.Authorization; -public sealed class ClientCredentialsGrantHandler(IClientCollection clientCollection, IPasswordHasher passwordHasher, ISecurityTokenService tokenService) : +public sealed class ClientCredentialsGrantHandler(IClientCollection clientCollection, ISecurityTokenService tokenService) : IAuthorizationFlowHandler { public Grant Grant => Grant.ClientCredentials; @@ -19,7 +19,7 @@ public async Task> HandleAsync(ClientAuthenti return Result.Failure(AuthenticationErrors.ClientNotFound); } - if (!await passwordHasher.VerifyPasswordAsync(parameters.ClientId + client.Name, client.Secret)) + if (parameters.ClientSecret != client.Secret) { return Result.Failure(AuthenticationErrors.InvalidClientCredentials); } From c0f5a6bec34b9c4efce0292608d8531e70a5cf5e Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 17 Apr 2026 15:38:40 -0300 Subject: [PATCH 076/117] feature(#20): rewrite the tests to explicitly create and authenticate clients via the /api/v1/clients endpoint, removing the dependency on realms such as client_id and client_secret. --- .../Endpoints/ConnectEndpointTests.cs | 120 +++++++++++++----- 1 file changed, 88 insertions(+), 32 deletions(-) diff --git a/Applications/Backend/Tests/Integration/Endpoints/ConnectEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/ConnectEndpointTests.cs index d479b3d..73c2203 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/ConnectEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/ConnectEndpointTests.cs @@ -5,11 +5,13 @@ public sealed class ConnectEndpointTests(IntegrationEnvironmentFixture factory) { private readonly Fixture _fixture = new(); - [Fact(DisplayName = "[e2e] - when POST /openid/connect/token with valid realm credentials should return access token")] - public async Task WhenPostTokenWithValidRealmCredentials_ShouldReturnAccessToken() + [Fact(DisplayName = "[e2e] - when POST /openid/connect/token with valid client credentials should return access token")] + public async Task WhenPostTokenWithValidClientCredentials_ShouldReturnAccessToken() { /* arrange: authenticate user and get access token */ + var clientCollection = factory.Services.GetRequiredService(); var httpClient = factory.HttpClient.WithRealmHeader("master"); + var userCredentials = new AuthenticationCredentials { Username = "federation.testing.user", @@ -24,35 +26,46 @@ public async Task WhenPostTokenWithValidRealmCredentials_ShouldReturnAccessToken httpClient.WithAuthorization(authentication.AccessToken); - /* arrange: create a realm to use as client */ - var payload = _fixture.Build() - .With(realm => realm.Name, $"test-realm-{Guid.NewGuid()}") - .With(realm => realm.Description, $"test-description-{Guid.NewGuid()}") + /* arrange: create a client */ + var payload = _fixture.Build() + .With(client => client.Name, "root") + .With(client => client.Flows, [Grant.ClientCredentials]) + .With(client => client.RedirectUris, []) .Create(); - var realmResponse = await httpClient.PostAsJsonAsync("api/v1/realms", payload); - var realm = await realmResponse.Content.ReadFromJsonAsync(); + var response = await httpClient.PostAsJsonAsync("api/v1/clients", payload); - Assert.NotNull(realm); - Assert.Equal(HttpStatusCode.Created, realmResponse.StatusCode); + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var filters = ClientFilters.WithSpecifications() + .WithName(payload.Name) + .Build(); + + var clients = await clientCollection.GetClientsAsync(filters); + var client = clients.FirstOrDefault(); - /* arrange: prepare client credentials using realm data */ + Assert.NotEmpty(clients); + Assert.NotNull(client); + + /* arrange: prepare client credentials */ var credentials = new Dictionary { { "grant_type", "client_credentials" }, - { "client_id", realm.ClientId }, - { "client_secret", realm.ClientSecret } + { "client_id", client.ClientId }, + { "client_secret", client.Secret } }; var content = new FormUrlEncodedContent(credentials); var connectClient = factory.HttpClient; /* act: send POST request to token endpoint */ - var response = await connectClient.PostAsync("api/v1/protocol/open-id/connect/token", content); - var grantedToken = await response.Content.ReadFromJsonAsync(); + var httpResponse = await connectClient.PostAsync("api/v1/protocol/open-id/connect/token", content); + var grantedToken = await httpResponse.Content.ReadFromJsonAsync(); /* assert: response should be 200 OK */ - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.NotNull(grantedToken); Assert.False(string.IsNullOrWhiteSpace(grantedToken.AccessToken)); } @@ -86,7 +99,9 @@ public async Task WhenPostTokenWithNonExistentClient_ShouldReturnUnauthorized() public async Task WhenPostTokenWithInvalidClientSecret_ShouldReturnUnauthorized() { /* arrange: authenticate user and get access token */ + var clientCollection = factory.Services.GetRequiredService(); var httpClient = factory.HttpClient.WithRealmHeader("master"); + var userCredentials = new AuthenticationCredentials { Username = "federation.testing.user", @@ -100,37 +115,47 @@ public async Task WhenPostTokenWithInvalidClientSecret_ShouldReturnUnauthorized( httpClient.WithAuthorization(authentication.AccessToken); - /* arrange: create a realm */ - var payload = _fixture.Build() - .With(realm => realm.Name, $"test-realm-{Guid.NewGuid()}") - .With(realm => realm.Description, $"test-description-{Guid.NewGuid()}") + /* arrange: create a client */ + var payload = _fixture.Build() + .With(client => client.Name, "admin") + .With(client => client.Flows, [Grant.ClientCredentials]) + .With(client => client.RedirectUris, []) .Create(); - var httpResponse = await httpClient.PostAsJsonAsync("api/v1/realms", payload); - var realm = await httpResponse.Content.ReadFromJsonAsync(); + var response = await httpClient.PostAsJsonAsync("api/v1/clients", payload); - Assert.NotNull(realm); + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var filters = ClientFilters.WithSpecifications() + .WithName(payload.Name) + .Build(); + + var clients = await clientCollection.GetClientsAsync(filters); + var client = clients.FirstOrDefault(); + + Assert.NotEmpty(clients); + Assert.NotNull(client); /* arrange: prepare credentials with wrong secret */ var credentials = new Dictionary { { "grant_type", "client_credentials" }, - { "client_id", realm.ClientId }, + { "client_id", client.ClientId }, { "client_secret", "wrong-secret" } }; /* act: send POST request with invalid secret */ - var content = new FormUrlEncodedContent(credentials); var connectClient = factory.HttpClient; - var response = await connectClient.PostAsync("api/v1/protocol/open-id/connect/token", content); - var error = await response.Content.ReadFromJsonAsync(); + var httpResponse = await connectClient.PostAsync("api/v1/protocol/open-id/connect/token", content); + var error = await httpResponse.Content.ReadFromJsonAsync(); /* assert: response should be 401 Unauthorized */ Assert.NotNull(error); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, httpResponse.StatusCode); Assert.Equal(AuthenticationErrors.InvalidClientCredentials, error); } @@ -200,14 +225,16 @@ public async Task WhenPostTokenWithValidAuthorizationCode_ShouldReturnAccessToke // arrange: resolve required dependencies var tokenCollection = factory.Services.GetRequiredService(); var userCollection = factory.Services.GetRequiredService(); + var clientCollection = factory.Services.GetRequiredService(); - // arrange: authenticate as master to create realm + // arrange: authenticate as master to create client and realm var masterClient = factory.HttpClient.WithRealmHeader("master"); var masterCredentials = new AuthenticationCredentials { Username = "federation.testing.user", Password = "federation.testing.password" }; + var authentication = await masterClient.PostAsJsonAsync("api/v1/identity/authenticate", masterCredentials); var grantedToken = await authentication.Content.ReadFromJsonAsync(); @@ -217,17 +244,46 @@ public async Task WhenPostTokenWithValidAuthorizationCode_ShouldReturnAccessToke masterClient.WithAuthorization(grantedToken.AccessToken); // arrange: create realm - var payload = _fixture.Build() + var realmPayload = _fixture.Build() .With(realm => realm.Name, $"test-realm-{Guid.NewGuid()}") .With(realm => realm.Description, $"test-description-{Guid.NewGuid()}") .Create(); - var realmResponse = await masterClient.PostAsJsonAsync("api/v1/realms", payload); + var realmResponse = await masterClient.PostAsJsonAsync("api/v1/realms", realmPayload); var realm = await realmResponse.Content.ReadFromJsonAsync(); Assert.NotNull(realm); Assert.Equal(HttpStatusCode.Created, realmResponse.StatusCode); + // arrange: create client for authorization_code grant + var realmMasterClient = factory.HttpClient.WithRealmHeader(realm.Name); + var realmAuth = await realmMasterClient.PostAsJsonAsync("api/v1/identity/authenticate", masterCredentials); + + var realmAdminClient = factory.HttpClient + .WithRealmHeader(realm.Name) + .WithAuthorization(grantedToken.AccessToken); + + var payload = _fixture.Build() + .With(client => client.Name, "root") + .With(client => client.Flows, [Grant.ClientCredentials]) + .With(client => client.RedirectUris, []) + .Create(); + + var httpResponse = await realmAdminClient.PostAsJsonAsync("api/v1/clients", payload); + + Assert.NotNull(httpResponse); + Assert.Equal(HttpStatusCode.Created, httpResponse.StatusCode); + + var clientFilters = ClientFilters.WithSpecifications() + .WithName(payload.Name) + .Build(); + + var clients = await clientCollection.GetClientsAsync(clientFilters); + var client = clients.FirstOrDefault(); + + Assert.NotEmpty(clients); + Assert.NotNull(client); + // arrange: create user for realm var credentials = new IdentityEnrollmentCredentials { @@ -297,7 +353,7 @@ public async Task WhenPostTokenWithValidAuthorizationCode_ShouldReturnAccessToke { { "grant_type", "authorization_code" }, { "code", authorizationCode }, - { "client_id", realm.ClientId }, + { "client_id", client.ClientId }, { "code_verifier", codeVerifier } }; From 9204baf8e585017b26b4ecd9f4cb85b1aa6bb6a6 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 17 Apr 2026 16:10:52 -0300 Subject: [PATCH 077/117] fix(#20): this commit fixes the test logic, which previously treated realms as clients --- .../Endpoints/PermissionEndpointTests.cs | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/Applications/Backend/Tests/Integration/Endpoints/PermissionEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/PermissionEndpointTests.cs index e923db6..d94fa85 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/PermissionEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/PermissionEndpointTests.cs @@ -142,12 +142,49 @@ public async Task WhenPostPermissionsWithReservedSystemNameInNonMasterRealm_Shou Assert.NotNull(realm); Assert.Equal(HttpStatusCode.Created, realmResponse.StatusCode); - /* arrange: authenticate realm via OAuth 2.0 client_credentials */ + /* arrange: create a client scoped to the new realm */ + var clientCollection = factory.Services.GetRequiredService(); + var realmAdminClient = factory.HttpClient + .WithRealmHeader(realm.Name) + .WithAuthorization(masterAuthenticationResult.AccessToken); + + var clientPayload = _fixture.Build() + .With(client => client.Name, "nubank") + .With(client => client.Flows, [Grant.ClientCredentials]) + .With(client => client.RedirectUris, []) + .Create(); + + var clientResponse = await realmAdminClient.PostAsJsonAsync("api/v1/clients", clientPayload); + + Assert.NotNull(clientResponse); + Assert.Equal(HttpStatusCode.Created, clientResponse.StatusCode); + + var clientFilters = ClientFilters.WithSpecifications() + .WithName(clientPayload.Name) + .Build(); + + var clients = await clientCollection.GetClientsAsync(clientFilters); + var client = clients.FirstOrDefault(); + + Assert.NotEmpty(clients); + Assert.NotNull(client); + + /* arrange: assign CreatePermission to the client using the master-scoped admin client */ + var assignPayload = _fixture.Build() + .With(assignment => assignment.PermissionName, Permissions.CreatePermission) + .Create(); + + var assignment = await realmAdminClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/permissions", assignPayload); + + Assert.NotNull(assignment); + Assert.Equal(HttpStatusCode.OK, assignment.StatusCode); + + /* arrange: authenticate via OAuth 2.0 client_credentials using the created client */ var oauthCredentials = new Dictionary { { "grant_type", "client_credentials" }, - { "client_id", realm.ClientId }, - { "client_secret", realm.ClientSecret } + { "client_id", client.ClientId }, + { "client_secret", client.Secret } }; var oauthContent = new FormUrlEncodedContent(oauthCredentials); From 6abffeddeb6f8eba6673b7bed448ac0c476d82c4 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 17 Apr 2026 16:33:30 -0300 Subject: [PATCH 078/117] feature(#20): this commit fixes the logic of the validation test where clients/realms cannot create permissions reserved by the system --- .../Endpoints/PermissionEndpointTests.cs | 73 ++----------------- 1 file changed, 8 insertions(+), 65 deletions(-) diff --git a/Applications/Backend/Tests/Integration/Endpoints/PermissionEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/PermissionEndpointTests.cs index d94fa85..dd3bc52 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/PermissionEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/PermissionEndpointTests.cs @@ -133,7 +133,7 @@ public async Task WhenPostPermissionsWithReservedSystemNameInNonMasterRealm_Shou /* arrange: create a new realm */ var realmPayload = _fixture.Build() - .With(realm => realm.Name, $"test-realm-{Guid.NewGuid()}") + .With(realm => realm.Name, $"realm-{Guid.NewGuid()}") .Create(); var realmResponse = await masterClient.PostAsJsonAsync("api/v1/realms", realmPayload); @@ -142,79 +142,22 @@ public async Task WhenPostPermissionsWithReservedSystemNameInNonMasterRealm_Shou Assert.NotNull(realm); Assert.Equal(HttpStatusCode.Created, realmResponse.StatusCode); - /* arrange: create a client scoped to the new realm */ - var clientCollection = factory.Services.GetRequiredService(); - var realmAdminClient = factory.HttpClient - .WithRealmHeader(realm.Name) + /* arrange: use an authenticated identity in the target realm */ + var realmClient = factory.HttpClient.WithRealmHeader(realm.Name) .WithAuthorization(masterAuthenticationResult.AccessToken); - var clientPayload = _fixture.Build() - .With(client => client.Name, "nubank") - .With(client => client.Flows, [Grant.ClientCredentials]) - .With(client => client.RedirectUris, []) - .Create(); - - var clientResponse = await realmAdminClient.PostAsJsonAsync("api/v1/clients", clientPayload); - - Assert.NotNull(clientResponse); - Assert.Equal(HttpStatusCode.Created, clientResponse.StatusCode); - - var clientFilters = ClientFilters.WithSpecifications() - .WithName(clientPayload.Name) - .Build(); - - var clients = await clientCollection.GetClientsAsync(clientFilters); - var client = clients.FirstOrDefault(); - - Assert.NotEmpty(clients); - Assert.NotNull(client); - - /* arrange: assign CreatePermission to the client using the master-scoped admin client */ - var assignPayload = _fixture.Build() - .With(assignment => assignment.PermissionName, Permissions.CreatePermission) - .Create(); - - var assignment = await realmAdminClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/permissions", assignPayload); - - Assert.NotNull(assignment); - Assert.Equal(HttpStatusCode.OK, assignment.StatusCode); - - /* arrange: authenticate via OAuth 2.0 client_credentials using the created client */ - var oauthCredentials = new Dictionary + /* act: attempt to create a permission using a reserved system name */ + var payload = new PermissionCreationScheme { - { "grant_type", "client_credentials" }, - { "client_id", client.ClientId }, - { "client_secret", client.Secret } + Name = Permissions.ViewRealms }; - var oauthContent = new FormUrlEncodedContent(oauthCredentials); - var connectClient = factory.HttpClient; - - var oauthResponse = await connectClient.PostAsync("api/v1/protocol/open-id/connect/token", oauthContent); - var oauthResult = await oauthResponse.Content.ReadFromJsonAsync(); - - Assert.Equal(HttpStatusCode.OK, oauthResponse.StatusCode); - - Assert.NotNull(oauthResult); - Assert.NotEmpty(oauthResult.AccessToken); - - var realmClient = factory.HttpClient.WithRealmHeader(realm.Name); - - realmClient.WithAuthorization(oauthResult.AccessToken); - - /* act: attempt to create a permission using a reserved system name */ - var payload = _fixture.Build() - .With(permission => permission.Name, Permissions.ViewRealms) - .Create(); - var response = await realmClient.PostAsJsonAsync("api/v1/permissions", payload); - - /* assert: response should be 409 Conflict */ - Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); - var error = await response.Content.ReadFromJsonAsync(); + /* assert: response should be 409 Conflict */ Assert.NotNull(error); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); Assert.Equal(PermissionErrors.PermissionNameIsReserved, error); } From 43ee72a18c95ce908785579653b49b1c666608f9 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 17 Apr 2026 19:19:52 -0300 Subject: [PATCH 079/117] feature(#20): this commit removes client-id references from realms and references --- .../Mappers/RealmMapper.cs | 1 - .../Payloads/Realm/RealmDetailsScheme.cs | 2 -- .../Filtering/Builders/RealmFiltersBuilder.cs | 7 ------- .../Filtering/RealmFilters.cs | 1 - 4 files changed, 11 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/RealmMapper.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/RealmMapper.cs index 5bfd7ad..f8492d7 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/RealmMapper.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/RealmMapper.cs @@ -28,7 +28,6 @@ public static Realm AsRealm(RealmUpdateScheme payload, Realm realm) public static RealmFilters AsFilters(RealmFetchParameters parameters) => new() { Id = parameters.Id, - ClientId = parameters.ClientId, Name = parameters.Name, Pagination = parameters.Pagination, Sort = parameters.Sort, diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Realm/RealmDetailsScheme.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Realm/RealmDetailsScheme.cs index e317ef6..b3eed5d 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Realm/RealmDetailsScheme.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Realm/RealmDetailsScheme.cs @@ -5,6 +5,4 @@ public sealed record RealmDetailsScheme public string Id { get; init; } = default!; public string Name { get; init; } = default!; public string Description { get; init; } = default!; - public string ClientId { get; init; } = default!; - public string ClientSecret { get; init; } = default!; } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/RealmFiltersBuilder.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/RealmFiltersBuilder.cs index 66b5cae..ff2afa0 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/RealmFiltersBuilder.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/RealmFiltersBuilder.cs @@ -9,11 +9,4 @@ public RealmFiltersBuilder WithName(string? name) return this; } - - public RealmFiltersBuilder WithClientId(string? clientId) - { - _filters.ClientId = clientId; - - 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 bd3b7fc..e8acd5e 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/RealmFilters.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/RealmFilters.cs @@ -3,7 +3,6 @@ namespace HttpsRichardy.Federation.Domain.Filtering; public sealed class RealmFilters : Filters { public string? Name { get; set; } - public string? ClientId { get; set; } public static RealmFiltersBuilder WithSpecifications() => new(); } From 60bc136764368f3d2758c1955089c1652b019747 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 17 Apr 2026 19:26:04 -0300 Subject: [PATCH 080/117] =?UTF-8?q?feature(#20):=20this=20commit=20fixes?= =?UTF-8?q?=20the=20authorization=20logic=20to=20support=20client=20author?= =?UTF-8?q?ization=20via=20the=20=E2=80=9Cauthorization=5Fcode=E2=80=9D=20?= =?UTF-8?q?flow,=20which=20previously=20treated=20realms=20as=20clients.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Pages/Authorize.cshtml | 2 +- .../Pages/Authorize.cshtml.cs | 40 ++++++++++++++++--- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Pages/Authorize.cshtml b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Pages/Authorize.cshtml index 160d98e..fdc0b30 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Pages/Authorize.cshtml +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Pages/Authorize.cshtml @@ -7,7 +7,7 @@
@if ( - ViewData.ModelState.ContainsKey(RealmErrors.RealmDoesNotExist.Code) || + ViewData.ModelState.ContainsKey(ClientErrors.ClientDoesNotExist.Code) || ViewData.ModelState.ContainsKey(AuthorizationErrors.RedirectUriNotAllowed.Code) ) { diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Pages/Authorize.cshtml.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Pages/Authorize.cshtml.cs index cb9387d..25ea918 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Pages/Authorize.cshtml.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Pages/Authorize.cshtml.cs @@ -7,6 +7,7 @@ public sealed class AuthorizePage : PageModel private readonly ITokenCollection _tokenCollection; private readonly IRealmCollection _realmCollection; + private readonly IClientCollection _clientCollection; private readonly IRealmProvider _realmProvider; #region constructors @@ -15,13 +16,15 @@ public AuthorizePage( IUserCollection userCollection, IRealmProvider realmProvider, IRealmCollection realmCollection, - ITokenCollection tokenCollection) + ITokenCollection tokenCollection, + IClientCollection clientCollection) { _dispatcher = dispatcher; _userCollection = userCollection; _realmCollection = realmCollection; _realmProvider = realmProvider; _tokenCollection = tokenCollection; + _clientCollection = clientCollection; } #endregion @@ -33,12 +36,29 @@ public AuthorizePage( public async Task OnGetAsync() { - var filters = RealmFilters.WithSpecifications() + var filters = ClientFilters.WithSpecifications() .WithClientId(Parameters.ClientId) .Build(); - var realms = await _realmCollection.GetRealmsAsync(filters); - var realm = realms.FirstOrDefault(); + var clients = await _clientCollection.GetClientsAsync(filters); + var client = clients.FirstOrDefault(); + + if (client is null) + { + ModelState.AddModelError( + key: ClientErrors.ClientDoesNotExist.Code, + errorMessage: ClientErrors.ClientDoesNotExist.Description + ); + + return Page(); + } + + var realmFilters = RealmFilters.WithSpecifications() + .WithIdentifier(client.RealmId) + .Build(); + + var realms = await _realmCollection.GetRealmsAsync(realmFilters); + var realm = realms.First(); if (realm is null) { @@ -71,7 +91,11 @@ public async Task OnPostAsync() var result = await _dispatcher.DispatchAsync(Credentials); if (result.IsFailure) { - ModelState.AddModelError(result.Error.Code, result.Error.Description); + ModelState.AddModelError( + key: result.Error.Code, + errorMessage: result.Error.Description + ); + return Page(); } @@ -86,7 +110,11 @@ public async Task OnPostAsync() if (user is null) { - ModelState.AddModelError(AuthenticationErrors.UserNotFound.Code, AuthenticationErrors.UserNotFound.Description); + ModelState.AddModelError( + key: AuthenticationErrors.UserNotFound.Code, + errorMessage: AuthenticationErrors.UserNotFound.Description + ); + return Page(); } From a7557a451beebc79100ff1e0c0b42042e7d9c174 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 17 Apr 2026 19:35:06 -0300 Subject: [PATCH 081/117] feature(#20): this commit introduces persistence tests to verify that everything works correctly with persistence and client reads. --- .../Persistence/ClientPersistenceTests.cs | 344 +++++++++++++++++- 1 file changed, 343 insertions(+), 1 deletion(-) diff --git a/Applications/Backend/Tests/Integration/Persistence/ClientPersistenceTests.cs b/Applications/Backend/Tests/Integration/Persistence/ClientPersistenceTests.cs index d7bbcab..098c30f 100644 --- a/Applications/Backend/Tests/Integration/Persistence/ClientPersistenceTests.cs +++ b/Applications/Backend/Tests/Integration/Persistence/ClientPersistenceTests.cs @@ -23,9 +23,16 @@ public ClientPersistenceTests(MongoDatabaseFixture mongoFixture) public async Task WhenInsertingAClient_ThenItMustPersistInTheDatabase() { /* arrange: create client and matching filter */ + var realm = _fixture.Create(); + + _realmProvider.Setup(provider => provider.GetCurrentRealm()) + .Returns(realm); + var client = _fixture.Build() .With(client => client.Name, "federation-admin") + .With(client => client.ClientId, "federation-admin-id") .With(client => client.IsDeleted, false) + .With(client => client.RealmId, realm.Id) .Create(); var filters = ClientFilters.WithSpecifications() @@ -45,9 +52,344 @@ public async Task WhenInsertingAClient_ThenItMustPersistInTheDatabase() Assert.Equal(client.Name, retrievedClient.Name); } + [Fact(DisplayName = "[infrastructure] - when updating a client, then updated fields must persist")] + public async Task WhenUpdatingAClient_ThenUpdatedFieldsMustPersist() + { + /* arrange: create and insert client */ + var realm = _fixture.Create(); + + _realmProvider.Setup(provider => provider.GetCurrentRealm()) + .Returns(realm); + + var client = _fixture.Build() + .With(client => client.Name, "update.test") + .With(client => client.ClientId, "update.test.client") + .With(client => client.IsDeleted, false) + .With(client => client.RealmId, realm.Id) + .Create(); + + await _clientCollection.InsertAsync(client); + + /* act: update name and save */ + var newName = "updated.client"; + + client.Name = newName; + + await _clientCollection.UpdateAsync(client); + + var filters = ClientFilters.WithSpecifications() + .WithName(newName) + .Build(); + + var result = await _clientCollection.GetClientsAsync(filters, CancellationToken.None); + var updatedClient = result.FirstOrDefault(); + + /* assert: updated client must be found with new name */ + Assert.NotNull(updatedClient); + + Assert.Equal(client.Id, updatedClient.Id); + Assert.Equal(newName, updatedClient.Name); + } + + [Fact(DisplayName = "[infrastructure] - when deleting a client, then it must be marked as deleted and not returned by filters")] + public async Task WhenDeletingAClient_ThenItMustBeMarkedDeletedAndExcludedFromResults() + { + /* arrange: create and insert client */ + var realm = _fixture.Create(); + + _realmProvider.Setup(provider => provider.GetCurrentRealm()) + .Returns(realm); + + var client = _fixture.Build() + .With(client => client.Name, "delete.test") + .With(client => client.ClientId, "delete.test.client") + .With(client => client.IsDeleted, false) + .With(client => client.RealmId, realm.Id) + .Create(); + + await _clientCollection.InsertAsync(client); + + var filters = ClientFilters.WithSpecifications() + .WithName(client.Name) + .Build(); + + /* act: delete client and query by name */ + var deleted = await _clientCollection.DeleteAsync(client); + + var resultAfterDelete = await _clientCollection.GetClientsAsync(filters, CancellationToken.None); + + /* assert: no clients should be returned after delete */ + Assert.DoesNotContain(resultAfterDelete, current => current.Id == client.Id); + + /* arrange: prepare filters including deleted clients */ + var filtersWithDeleted = ClientFilters.WithSpecifications() + .WithName(client.Name) + .WithIsDeleted(true) + .Build(); + + /* act: refetch clients including deleted */ + var resultWithDeleted = await _clientCollection.GetClientsAsync(filtersWithDeleted, CancellationToken.None); + + /* assert: client should be returned when including deleted clients */ + Assert.Contains(resultWithDeleted, current => current.Id == client.Id); + + Assert.True(client.IsDeleted); + Assert.True(deleted); + } + + [Fact(DisplayName = "[infrastructure] - when filtering clients by id, then it must return matching client")] + public async Task WhenFilteringClientsById_ThenItMustReturnMatchingClient() + { + /* arrange: insert two clients */ + var realm = _fixture.Create(); + + _realmProvider.Setup(provider => provider.GetCurrentRealm()) + .Returns(realm); + + var client1 = _fixture.Build() + .With(client => client.IsDeleted, false) + .With(client => client.RealmId, realm.Id) + .Create(); + + var client2 = _fixture.Build() + .With(client => client.IsDeleted, false) + .With(client => client.RealmId, realm.Id) + .Create(); + + await _clientCollection.InsertAsync(client1); + await _clientCollection.InsertAsync(client2); + + var filters = ClientFilters.WithSpecifications() + .WithIdentifier(client1.Id) + .Build(); + + /* act: query clients filtered by id */ + var filteredClients = await _clientCollection.GetClientsAsync(filters, CancellationToken.None); + + /* assert: only client1 should be returned */ + Assert.Single(filteredClients); + Assert.Equal(client1.Id, filteredClients.First().Id); + } + + [Fact(DisplayName = "[infrastructure] - when filtering clients by current realm, then it must return matching clients")] + public async Task WhenFilteringClientsByCurrentRealm_ThenItMustReturnMatchingClients() + { + /* arrange: insert two clients with different realm ids */ + var realm = _fixture.Create(); + var anotherRealm = _fixture.Create(); + + _realmProvider.Setup(provider => provider.GetCurrentRealm()) + .Returns(realm); + + var client1 = _fixture.Build() + .With(client => client.RealmId, realm.Id) + .With(client => client.IsDeleted, false) + .Create(); + + var client2 = _fixture.Build() + .With(client => client.RealmId, anotherRealm.Id) + .With(client => client.IsDeleted, false) + .Create(); + + await _clientCollection.InsertAsync(client1); + await _clientCollection.InsertAsync(client2); + + var filters = ClientFilters.WithSpecifications() + .Build(); + + /* act: query clients filtered by current realm */ + var filteredClients = await _clientCollection.GetClientsAsync(filters, CancellationToken.None); + + /* assert: only client1 should be returned */ + Assert.Single(filteredClients); + Assert.Equal(client1.Id, filteredClients.First().Id); + } + + [Fact(DisplayName = "[infrastructure] - when filtering clients by name, then it must return matching clients")] + public async Task WhenFilteringClientsByName_ThenItMustReturnMatchingClients() + { + /* arrange: insert two clients with different names */ + var realm = _fixture.Create(); + + _realmProvider.Setup(provider => provider.GetCurrentRealm()) + .Returns(realm); + + var client1 = _fixture.Build() + .With(client => client.Name, "filter1") + .With(client => client.IsDeleted, false) + .With(client => client.RealmId, realm.Id) + .Create(); + + var client2 = _fixture.Build() + .With(client => client.Name, "filter2") + .With(client => client.IsDeleted, false) + .With(client => client.RealmId, realm.Id) + .Create(); + + await _clientCollection.InsertAsync(client1); + await _clientCollection.InsertAsync(client2); + + var filters = ClientFilters.WithSpecifications() + .WithName("filter1") + .Build(); + + /* act: query clients filtered by name */ + var filteredClients = await _clientCollection.GetClientsAsync(filters, CancellationToken.None); + + /* assert: only client1 should be returned */ + Assert.Single(filteredClients); + Assert.Equal(client1.Id, filteredClients.First().Id); + } + + [Fact(DisplayName = "[infrastructure] - when filtering clients by client id, then it must return matching clients")] + public async Task WhenFilteringClientsByClientId_ThenItMustReturnMatchingClients() + { + /* arrange: insert two clients with different client ids */ + var realm = _fixture.Create(); + + _realmProvider.Setup(provider => provider.GetCurrentRealm()) + .Returns(realm); + + var client1 = _fixture.Build() + .With(client => client.ClientId, "client.filter.1") + .With(client => client.IsDeleted, false) + .With(client => client.RealmId, realm.Id) + .Create(); + + var client2 = _fixture.Build() + .With(client => client.ClientId, "client.filter.2") + .With(client => client.IsDeleted, false) + .With(client => client.RealmId, realm.Id) + .Create(); + + await _clientCollection.InsertAsync(client1); + await _clientCollection.InsertAsync(client2); + + var filters = ClientFilters.WithSpecifications() + .WithClientId("client.filter.1") + .Build(); + + /* act: query clients filtered by client id */ + var filteredClients = await _clientCollection.GetClientsAsync(filters, CancellationToken.None); + + /* assert: only client1 should be returned */ + Assert.Single(filteredClients); + Assert.Equal(client1.Id, filteredClients.First().Id); + } + + [Fact(DisplayName = "[infrastructure] - when paginating 10 clients with page size 5, then it must return 5 clients per page")] + public async Task WhenPaginatingTenClients_ThenItMustReturnFiveClientsPerPage() + { + /* arrange: create and insert 10 clients, all not deleted */ + var realm = _fixture.Create(); + + _realmProvider.Setup(provider => provider.GetCurrentRealm()) + .Returns(realm); + + var clients = Enumerable.Range(1, 10) + .Select(index => _fixture.Build() + .With(client => client.Name, $"client.{index}") + .With(client => client.ClientId, $"client-id.{index}") + .With(client => client.IsDeleted, false) + .With(client => client.RealmId, realm.Id) + .Create()) + .ToList(); + + foreach (var client in clients) + { + await _clientCollection.InsertAsync(client); + } + + /* arrange: prepare filters for page 1 with page size 5 */ + var filtersPage1 = ClientFilters.WithSpecifications() + .WithPagination(PaginationFilters.From(pageNumber: 1, pageSize: 5)) + .Build(); + + /* act: get first page */ + var page1Results = await _clientCollection.GetClientsAsync(filtersPage1, CancellationToken.None); + + /* assert: page 1 should return exactly 5 clients */ + Assert.Equal(5, page1Results.Count); + + /* arrange: prepare filters for page 2 with page size 5 */ + var filtersPage2 = ClientFilters.WithSpecifications() + .WithPagination(PaginationFilters.From(pageNumber: 2, pageSize: 5)) + .Build(); + + /* act: get second page */ + var page2Results = await _clientCollection.GetClientsAsync(filtersPage2, CancellationToken.None); + + /* assert: page 2 should return exactly 5 clients */ + Assert.Equal(5, page2Results.Count); + } + + [Fact(DisplayName = "[infrastructure] - when counting 10 clients with isDeleted = false, then it must return 10")] + public async Task WhenCountingTenClientsWithIsDeletedFalse_ThenItMustReturnTen() + { + /* arrange: create and insert 10 clients, all not deleted */ + var realm = _fixture.Create(); + + _realmProvider.Setup(provider => provider.GetCurrentRealm()) + .Returns(realm); + + var clients = Enumerable.Range(1, 10) + .Select(index => _fixture.Build() + .With(client => client.Name, $"client.{index}") + .With(client => client.ClientId, $"client-id.{index}") + .With(client => client.IsDeleted, false) + .With(client => client.RealmId, realm.Id) + .Create()) + .ToList(); + + await _clientCollection.InsertManyAsync(clients); + + /* arrange: prepare filters with IsDeleted = false */ + var filters = ClientFilters.WithSpecifications() + .WithIsDeleted(false) + .Build(); + + /* act: count clients matching filters */ + var total = await _clientCollection.CountClientsAsync(filters); + + /* assert: should return 10 */ + Assert.Equal(10, (int)total); + } + + [Fact(DisplayName = "[infrastructure] - when counting 10 clients with isDeleted = true, then it must return 0")] + public async Task WhenCountingTenClientsWithIsDeletedTrue_ThenItMustReturnZero() + { + /* arrange: create and insert 10 clients, all not deleted */ + var realm = _fixture.Create(); + + _realmProvider.Setup(provider => provider.GetCurrentRealm()) + .Returns(realm); + + var clients = Enumerable.Range(1, 10) + .Select(index => _fixture.Build() + .With(client => client.Name, $"client.{index}") + .With(client => client.ClientId, $"client-id.{index}") + .With(client => client.IsDeleted, false) + .With(client => client.RealmId, realm.Id) + .Create()) + .ToList(); + + await _clientCollection.InsertManyAsync(clients); + + /* arrange: prepare filters with IsDeleted = true */ + var filters = ClientFilters.WithSpecifications() + .WithIsDeleted(true) + .Build(); + + /* act: count clients matching filters */ + var total = await _clientCollection.CountClientsAsync(filters); + + /* assert: should return 0 */ + Assert.Equal(0, (int)total); + } + public async Task DisposeAsync() => await Task.CompletedTask; public async Task InitializeAsync() { await _mongoFixture.CleanDatabaseAsync(); } -} \ No newline at end of file +} From 9944bff76c375677fcfeb1075f918ac1f54c5287 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 17 Apr 2026 19:46:43 -0300 Subject: [PATCH 082/117] feature(#20): includes a suite of end-to-end tests covering paginated listings (GET), creation (POST), and updates (PUT) of clients, with validation of authentication, persistence, and API responses. --- .../Endpoints/ClientEndpointTests.cs | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs diff --git a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs new file mode 100644 index 0000000..8299add --- /dev/null +++ b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs @@ -0,0 +1,196 @@ +namespace HttpsRichardy.Federation.TestSuite.Integration.Endpoints; + +public sealed class ClientEndpointTests(IntegrationEnvironmentFixture factory) : + IClassFixture +{ + private readonly Fixture _fixture = new(); + + [Fact(DisplayName = "[e2e] - when GET /clients should return paginated list of clients")] + public async Task WhenGetClients_ShouldReturnPaginatedListOfClients() + { + /* arrange: resolve required dependencies */ + var clientCollection = factory.Services.GetRequiredService(); + var realmCollection = factory.Services.GetRequiredService(); + + /* 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: insert 5 clients directly into the database */ + var realmFilters = RealmFilters.WithSpecifications() + .WithName("master") + .Build(); + + var realms = await realmCollection.GetRealmsAsync(realmFilters, CancellationToken.None); + var realm = realms.FirstOrDefault(); + + Assert.NotNull(realm); + + var clients = Enumerable.Range(1, 5) + .Select(index => _fixture.Build() + .With(client => client.Name, $"test-client-{index}") + .With(client => client.ClientId, $"test-client-id-{index}") + .With(client => client.Secret, $"test-client-secret-{index}") + .With(client => client.RealmId, realm.Id) + .With(client => client.IsDeleted, false) + .Create()) + .ToList(); + + await clientCollection.InsertManyAsync(clients); + + /* act: send GET request to retrieve clients */ + var response = await httpClient.GetAsync("api/v1/clients"); + var result = await response.Content.ReadFromJsonAsync>(); + + /* assert: response should be 200 OK */ + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + Assert.NotNull(result); + Assert.NotNull(result.Items); + + /* assert: inserted clients should be present in the returned list */ + foreach (var client in clients) + { + Assert.Contains(result.Items, item => item.Id == client.Id); + } + } + + [Fact(DisplayName = "[e2e] - when POST /clients with valid data should create client successfully")] + public async Task WhenPostClientsWithValidData_ShouldCreateClientSuccessfully() + { + /* arrange: resolve required dependencies */ + var clientCollection = factory.Services.GetRequiredService(); + + /* 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 to create a new client */ + var payload = _fixture.Build() + .With(client => client.Name, $"test-client-{Guid.NewGuid()}") + .With(client => client.Flows, [Grant.ClientCredentials]) + .With(client => client.RedirectUris, []) + .Create(); + + /* act: send POST request to create client */ + var response = await httpClient.PostAsJsonAsync("api/v1/clients", payload); + + /* assert: response should be 201 Created */ + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + /* assert: client must be persisted in the repository */ + var filters = ClientFilters.WithSpecifications() + .WithName(payload.Name) + .Build(); + + var clients = await clientCollection.GetClientsAsync(filters, CancellationToken.None); + var createdClient = clients.FirstOrDefault(); + + Assert.NotEmpty(clients); + Assert.NotNull(createdClient); + + Assert.Equal(payload.Name, createdClient.Name); + Assert.Equal(payload.Flows, createdClient.Flows); + } + + [Fact(DisplayName = "[e2e] - when PUT /clients/{id} with valid data should update client successfully")] + public async Task WhenPutClientsWithValidData_ShouldUpdateClientSuccessfully() + { + /* arrange: resolve required dependencies */ + var clientCollection = factory.Services.GetRequiredService(); + + /* 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 client */ + var createPayload = _fixture.Build() + .With(client => client.Name, $"test-client-{Guid.NewGuid()}") + .With(client => client.Flows, [Grant.ClientCredentials]) + .With(client => client.RedirectUris, []) + .Create(); + + var createResponse = await httpClient.PostAsJsonAsync("api/v1/clients", createPayload); + + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + var filters = ClientFilters.WithSpecifications() + .WithName(createPayload.Name) + .Build(); + + var clients = await clientCollection.GetClientsAsync(filters, CancellationToken.None); + var client = clients.FirstOrDefault(); + + Assert.NotEmpty(clients); + Assert.NotNull(client); + + /* arrange: prepare request to update client */ + var payload = _fixture.Build() + .With(update => update.Name, $"updated-client-{Guid.NewGuid()}") + .With(update => update.Flows, [Grant.AuthorizationCode]) + .With(update => update.RedirectUris, ["https://localhost/callback"]) + .Create(); + + /* act: send PUT request to update client */ + var response = await httpClient.PutAsJsonAsync($"api/v1/clients/{client.Id}", payload); + var result = await response.Content.ReadFromJsonAsync(); + + /* assert: response should be 200 OK */ + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(result); + + /* assert: client must be updated in the repository */ + var clientFilters = ClientFilters.WithSpecifications() + .WithName(payload.Name) + .Build(); + + var matchingClients = await clientCollection.GetClientsAsync(clientFilters, CancellationToken.None); + var persistedClient = matchingClients.FirstOrDefault(current => current.Id == client.Id); + + Assert.NotEmpty(matchingClients); + Assert.NotNull(persistedClient); + + Assert.Equal(client.Id, result.Id); + Assert.Equal(payload.Name, result.Name); + Assert.Equal(payload.Name, persistedClient.Name); + Assert.Equal(payload.Flows, persistedClient.Flows); + + Assert.Contains(persistedClient.RedirectUris, uri => uri.Address == "https://localhost/callback"); + } +} From 94ff9438fecd0d9379b4d87f40d395b3730b5d40 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 17 Apr 2026 19:48:29 -0300 Subject: [PATCH 083/117] feature(#20): this commit renames the argument variable in the lambda expression --- .../Tests/Integration/Endpoints/ClientEndpointTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs index 8299add..f46a03c 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs @@ -162,9 +162,9 @@ public async Task WhenPutClientsWithValidData_ShouldUpdateClientSuccessfully() /* arrange: prepare request to update client */ var payload = _fixture.Build() - .With(update => update.Name, $"updated-client-{Guid.NewGuid()}") - .With(update => update.Flows, [Grant.AuthorizationCode]) - .With(update => update.RedirectUris, ["https://localhost/callback"]) + .With(client => client.Name, $"updated-client-{Guid.NewGuid()}") + .With(client => client.Flows, [Grant.AuthorizationCode]) + .With(client => client.RedirectUris, ["https://localhost/callback"]) .Create(); /* act: send PUT request to update client */ From 206b5ffc1ea61c4c825d760f5efa8d1539939515 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 17 Apr 2026 19:54:59 -0300 Subject: [PATCH 084/117] feature(#20): this commit adds integration tests for deleting a client --- .../Endpoints/ClientEndpointTests.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs index f46a03c..f47c775 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs @@ -193,4 +193,54 @@ public async Task WhenPutClientsWithValidData_ShouldUpdateClientSuccessfully() Assert.Contains(persistedClient.RedirectUris, uri => uri.Address == "https://localhost/callback"); } + + [Fact(DisplayName = "[e2e] - when DELETE /clients/{id} with valid client should delete client successfully")] + public async Task WhenDeleteClientsWithValidClient_ShouldDeleteClientSuccessfully() + { + /* arrange: resolve required dependencies */ + var clientCollection = factory.Services.GetRequiredService(); + + /* 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 client to delete */ + var payload = _fixture.Build() + .With(client => client.Name, $"test-client-{Guid.NewGuid()}") + .With(client => client.Flows, [Grant.ClientCredentials]) + .With(client => client.RedirectUris, []) + .Create(); + + var createResponse = await httpClient.PostAsJsonAsync("api/v1/clients", payload); + var filters = ClientFilters.WithSpecifications() + .WithName(payload.Name) + .Build(); + + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + var clients = await clientCollection.GetClientsAsync(filters, CancellationToken.None); + var client = clients.FirstOrDefault(); + + Assert.NotEmpty(clients); + Assert.NotNull(client); + + /* act: send DELETE request to remove client */ + var response = await httpClient.DeleteAsync($"api/v1/clients/{client.Id}"); + var result = await clientCollection.GetClientsAsync(filters, CancellationToken.None); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.DoesNotContain(result, current => current.Id == client.Id); + } } From 77e9ecd5bce8f7d679181445cfc34361ea722b9e Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 17 Apr 2026 20:07:56 -0300 Subject: [PATCH 085/117] feature(#20): this commit adds integration tests to check for a 404 response when deleting a non-existent client, and a test to verify that permissions are obtained for a client --- .../Endpoints/ClientEndpointTests.cs | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs index f47c775..267feb8 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs @@ -243,4 +243,106 @@ public async Task WhenDeleteClientsWithValidClient_ShouldDeleteClientSuccessfull Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); Assert.DoesNotContain(result, current => current.Id == client.Id); } + + [Fact(DisplayName = "[e2e] - when DELETE /clients/{id} with non-existent client should return 404 #ERROR-2D943")] + public async Task WhenDeleteClientsWithNonExistentClient_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 client ID */ + var nonExistentClientId = Guid.NewGuid().ToString(); + + /* act: send DELETE request for non-existent client */ + var response = await httpClient.DeleteAsync($"api/v1/clients/{nonExistentClientId}"); + var error = await response.Content.ReadFromJsonAsync(); + + /* assert: response should be 404 Not Found */ + Assert.NotNull(error); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(ClientErrors.ClientDoesNotExist, error); + } + + [Fact(DisplayName = "[e2e] - when GET /clients/{id}/permissions should return client's assigned permissions")] + public async Task WhenGetClientPermissions_ShouldReturnAssignedPermissions() + { + /* arrange: resolve required dependencies */ + var clientCollection = factory.Services.GetRequiredService(); + var realmCollection = factory.Services.GetRequiredService(); + + /* 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 and insert client with assigned permissions */ + var realmFilters = RealmFilters.WithSpecifications() + .WithName("master") + .Build(); + + var realms = await realmCollection.GetRealmsAsync(realmFilters, CancellationToken.None); + var realm = realms.FirstOrDefault(); + + Assert.NotNull(realm); + + var permission1 = _fixture.Build() + .With(permission => permission.Name, $"test.permission.{Guid.NewGuid()}") + .With(permission => permission.RealmId, realm.Id) + .With(permission => permission.IsDeleted, false) + .Create(); + + var permission2 = _fixture.Build() + .With(permission => permission.Name, $"test.permission.{Guid.NewGuid()}") + .With(permission => permission.RealmId, realm.Id) + .With(permission => permission.IsDeleted, false) + .Create(); + + var client = _fixture.Build() + .With(current => current.Name, $"test-client-{Guid.NewGuid()}") + .With(current => current.ClientId, $"test-client-id-{Guid.NewGuid()}") + .With(current => current.Secret, $"test-client-secret-{Guid.NewGuid()}") + .With(current => current.RealmId, realm.Id) + .With(current => current.IsDeleted, false) + .With(current => current.Permissions, [permission1, permission2]) + .Create(); + + await clientCollection.InsertAsync(client); + + /* act: send GET request to retrieve client's permissions */ + var response = await httpClient.GetAsync($"api/v1/clients/{client.Id}/permissions"); + var permissions = await response.Content.ReadFromJsonAsync>(); + + /* assert: response should be 200 OK */ + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(permissions); + + /* assert: assigned permissions should be returned */ + Assert.Contains(permissions, permission => permission.Name == permission1.Name); + Assert.Contains(permissions, permission => permission.Name == permission2.Name); + } } From 90a771d87a0e1120f0d95f47e75246d634251f1d Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 17 Apr 2026 20:12:33 -0300 Subject: [PATCH 086/117] feature(#20): this commit adds integration tests for the endpoint that associates permissions with clients --- .../Endpoints/ClientEndpointTests.cs | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs index 267feb8..f3d0d2e 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs @@ -345,4 +345,167 @@ public async Task WhenGetClientPermissions_ShouldReturnAssignedPermissions() Assert.Contains(permissions, permission => permission.Name == permission1.Name); Assert.Contains(permissions, permission => permission.Name == permission2.Name); } + + [Fact(DisplayName = "[e2e] - when POST /clients/{id}/permissions with valid permission should assign permission successfully")] + public async Task WhenPostClientPermissionsWithValidPermission_ShouldAssignPermissionSuccessfully() + { + /* arrange: resolve required dependencies */ + var clientCollection = factory.Services.GetRequiredService(); + var permissionCollection = factory.Services.GetRequiredService(); + + /* 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 client */ + var clientPayload = _fixture.Build() + .With(client => client.Name, $"test-client-{Guid.NewGuid()}") + .With(client => client.Flows, [Grant.ClientCredentials]) + .With(client => client.RedirectUris, []) + .Create(); + + var clientResponse = await httpClient.PostAsJsonAsync("api/v1/clients", clientPayload); + + Assert.Equal(HttpStatusCode.Created, clientResponse.StatusCode); + + var clientFilters = ClientFilters.WithSpecifications() + .WithName(clientPayload.Name) + .Build(); + + var clients = await clientCollection.GetClientsAsync(clientFilters, CancellationToken.None); + var client = clients.FirstOrDefault(); + + Assert.NotEmpty(clients); + Assert.NotNull(client); + + /* arrange: create a new permission */ + var permissionPayload = _fixture.Build() + .With(permission => permission.Name, $"test.permission.{Guid.NewGuid()}") + .Create(); + + var permissionResponse = await httpClient.PostAsJsonAsync("api/v1/permissions", permissionPayload); + + Assert.Equal(HttpStatusCode.Created, permissionResponse.StatusCode); + + var permissionFilters = PermissionFilters.WithSpecifications() + .WithName(permissionPayload.Name) + .Build(); + + var permissions = await permissionCollection.GetPermissionsAsync(permissionFilters, CancellationToken.None); + var permission = permissions.FirstOrDefault(); + + Assert.NotEmpty(permissions); + Assert.NotNull(permission); + + /* arrange: prepare request to assign permission to client */ + var payload = _fixture.Build() + .With(assignment => assignment.PermissionName, permission.Name) + .Create(); + + /* act: send POST request to assign permission to client */ + var response = await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/permissions", payload); + var assignedPermissions = await response.Content.ReadFromJsonAsync>(); + + /* assert: response should be 200 OK and permissions list should be returned */ + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(assignedPermissions); + + /* assert: the assigned permission should be in the returned list */ + Assert.Contains(assignedPermissions, current => current.Name == permission.Name); + } + + [Fact(DisplayName = "[e2e] - when POST /clients/{id}/permissions with duplicate permission should return 409 #ERROR-8D71B")] + public async Task WhenPostClientPermissionsWithDuplicatePermission_ShouldReturnConflict() + { + /* arrange: resolve required dependencies */ + var clientCollection = factory.Services.GetRequiredService(); + var permissionCollection = factory.Services.GetRequiredService(); + + /* 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 client */ + var clientPayload = _fixture.Build() + .With(client => client.Name, $"test-client-{Guid.NewGuid()}") + .With(client => client.Flows, [Grant.ClientCredentials]) + .With(client => client.RedirectUris, []) + .Create(); + + var clientResponse = await httpClient.PostAsJsonAsync("api/v1/clients", clientPayload); + + Assert.Equal(HttpStatusCode.Created, clientResponse.StatusCode); + + var clientFilters = ClientFilters.WithSpecifications() + .WithName(clientPayload.Name) + .Build(); + + var clients = await clientCollection.GetClientsAsync(clientFilters, CancellationToken.None); + var client = clients.FirstOrDefault(); + + Assert.NotEmpty(clients); + Assert.NotNull(client); + + /* arrange: create a new permission */ + var permissionPayload = _fixture.Build() + .With(permission => permission.Name, $"test.permission.{Guid.NewGuid()}") + .Create(); + + var permissionResponse = await httpClient.PostAsJsonAsync("api/v1/permissions", permissionPayload); + + Assert.Equal(HttpStatusCode.Created, permissionResponse.StatusCode); + + var permissionFilters = PermissionFilters.WithSpecifications() + .WithName(permissionPayload.Name) + .Build(); + + var permissions = await permissionCollection.GetPermissionsAsync(permissionFilters, CancellationToken.None); + var permission = permissions.FirstOrDefault(); + + Assert.NotEmpty(permissions); + Assert.NotNull(permission); + + /* arrange: assign permission to client first time */ + var payload = _fixture.Build() + .With(assignment => assignment.PermissionName, permission.Name) + .Create(); + + var firstResponse = await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/permissions", payload); + + Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); + + /* act: attempt to assign the same permission again */ + var secondResponse = await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/permissions", payload); + var error = await secondResponse.Content.ReadFromJsonAsync(); + + /* assert: response should be 409 Conflict */ + Assert.NotNull(error); + + Assert.Equal(HttpStatusCode.Conflict, secondResponse.StatusCode); + Assert.Equal(ClientErrors.ClientAlreadyHasPermission, error); + } } From dc6096c237076572f81907cd7009d10847b640e7 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 17 Apr 2026 20:23:00 -0300 Subject: [PATCH 087/117] feature(#20): this commit adds integration tests for the permission revocation endpoint, covering the main scenarios --- .../Endpoints/ClientEndpointTests.cs | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) diff --git a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs index f3d0d2e..3f41b49 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs @@ -508,4 +508,256 @@ public async Task WhenPostClientPermissionsWithDuplicatePermission_ShouldReturnC Assert.Equal(HttpStatusCode.Conflict, secondResponse.StatusCode); Assert.Equal(ClientErrors.ClientAlreadyHasPermission, error); } + + [Fact(DisplayName = "[e2e] - when DELETE /clients/{id}/permissions/{permissionId} should revoke permission successfully")] + public async Task WhenDeleteClientPermission_ShouldRevokePermissionSuccessfully() + { + /* arrange: resolve required dependencies */ + var clientCollection = factory.Services.GetRequiredService(); + var permissionCollection = factory.Services.GetRequiredService(); + + /* 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 client */ + var clientPayload = _fixture.Build() + .With(client => client.Name, $"test-client-{Guid.NewGuid()}") + .With(client => client.Flows, [Grant.ClientCredentials]) + .With(client => client.RedirectUris, []) + .Create(); + + var clientResponse = await httpClient.PostAsJsonAsync("api/v1/clients", clientPayload); + + Assert.Equal(HttpStatusCode.Created, clientResponse.StatusCode); + + var clientFilters = ClientFilters.WithSpecifications() + .WithName(clientPayload.Name) + .Build(); + + var clients = await clientCollection.GetClientsAsync(clientFilters, CancellationToken.None); + var client = clients.FirstOrDefault(); + + Assert.NotEmpty(clients); + Assert.NotNull(client); + + /* arrange: create a new permission */ + var permissionPayload = _fixture.Build() + .With(permission => permission.Name, $"test.permission.{Guid.NewGuid()}") + .Create(); + + var permissionResponse = await httpClient.PostAsJsonAsync("api/v1/permissions", permissionPayload); + + Assert.Equal(HttpStatusCode.Created, permissionResponse.StatusCode); + + var permissionFilters = PermissionFilters.WithSpecifications() + .WithName(permissionPayload.Name) + .Build(); + + var permissions = await permissionCollection.GetPermissionsAsync(permissionFilters, CancellationToken.None); + var permission = permissions.FirstOrDefault(); + + Assert.NotEmpty(permissions); + Assert.NotNull(permission); + + /* arrange: assign permission to client */ + var assignPayload = _fixture.Build() + .With(assignment => assignment.PermissionName, permission.Name) + .Create(); + + var assignResponse = await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/permissions", assignPayload); + + Assert.Equal(HttpStatusCode.OK, assignResponse.StatusCode); + + /* act: send DELETE request to revoke permission from client */ + var response = await httpClient.DeleteAsync($"api/v1/clients/{client.Id}/permissions/{permission.Id}"); + + /* assert: response should be 204 No Content */ + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + + /* assert: verify permission is no longer in client's permissions list */ + var httpResponse = await httpClient.GetAsync($"api/v1/clients/{client.Id}/permissions"); + var assignedPermissions = await httpResponse.Content.ReadFromJsonAsync>(); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + + Assert.NotNull(assignedPermissions); + Assert.DoesNotContain(assignedPermissions, current => current.Id == permission.Id); + } + + [Fact(DisplayName = "[e2e] - when DELETE /clients/{id}/permissions/{permissionId} with non-existent client should return 404 #ERROR-2D943")] + public async Task WhenDeleteClientPermissionWithNonExistentClient_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 client ID */ + var nonExistentClientId = Guid.NewGuid().ToString(); + var nonExistentPermissionId = Guid.NewGuid().ToString(); + + /* act: send DELETE request for non-existent client */ + var response = await httpClient.DeleteAsync($"api/v1/clients/{nonExistentClientId}/permissions/{nonExistentPermissionId}"); + var error = await response.Content.ReadFromJsonAsync(); + + /* assert: response should be 404 Not Found */ + Assert.NotNull(error); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(ClientErrors.ClientDoesNotExist, error); + } + + [Fact(DisplayName = "[e2e] - when DELETE /clients/{id}/permissions/{permissionId} with non-existent permission should return 404 #ERROR-93697")] + public async Task WhenDeleteClientPermissionWithNonExistentPermission_ShouldReturnNotFound() + { + /* arrange: resolve required dependencies */ + var clientCollection = factory.Services.GetRequiredService(); + + /* 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 client */ + var clientPayload = _fixture.Build() + .With(client => client.Name, $"test-client-{Guid.NewGuid()}") + .With(client => client.Flows, [Grant.ClientCredentials]) + .With(client => client.RedirectUris, []) + .Create(); + + var clientResponse = await httpClient.PostAsJsonAsync("api/v1/clients", clientPayload); + + Assert.Equal(HttpStatusCode.Created, clientResponse.StatusCode); + + var clientFilters = ClientFilters.WithSpecifications() + .WithName(clientPayload.Name) + .Build(); + + var clients = await clientCollection.GetClientsAsync(clientFilters, CancellationToken.None); + var client = clients.FirstOrDefault(); + + Assert.NotEmpty(clients); + Assert.NotNull(client); + + /* arrange: prepare request with a non-existent permission ID */ + var nonExistentPermissionId = Guid.NewGuid().ToString(); + + /* act: send DELETE request with non-existent permission */ + var response = await httpClient.DeleteAsync($"api/v1/clients/{client.Id}/permissions/{nonExistentPermissionId}"); + var error = await response.Content.ReadFromJsonAsync(); + + /* assert: response should be 404 Not Found */ + Assert.NotNull(error); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(PermissionErrors.PermissionDoesNotExist, error); + } + + [Fact(DisplayName = "[e2e] - when DELETE /clients/{id}/permissions/{permissionId} with permission not assigned should return 409 #ERROR-C2FB0")] + public async Task WhenDeleteClientPermissionWithPermissionNotAssigned_ShouldReturnConflict() + { + /* arrange: resolve required dependencies */ + var clientCollection = factory.Services.GetRequiredService(); + var permissionCollection = factory.Services.GetRequiredService(); + + /* 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 client */ + var clientPayload = _fixture.Build() + .With(client => client.Name, $"test-client-{Guid.NewGuid()}") + .With(client => client.Flows, [Grant.ClientCredentials]) + .With(client => client.RedirectUris, []) + .Create(); + + var clientResponse = await httpClient.PostAsJsonAsync("api/v1/clients", clientPayload); + + Assert.Equal(HttpStatusCode.Created, clientResponse.StatusCode); + + var clientFilters = ClientFilters.WithSpecifications() + .WithName(clientPayload.Name) + .Build(); + + var clients = await clientCollection.GetClientsAsync(clientFilters, CancellationToken.None); + var client = clients.FirstOrDefault(); + + Assert.NotEmpty(clients); + Assert.NotNull(client); + + /* arrange: create a new permission without assigning it to the client */ + var permissionPayload = _fixture.Build() + .With(permission => permission.Name, $"test.permission.{Guid.NewGuid()}") + .Create(); + + var permissionResponse = await httpClient.PostAsJsonAsync("api/v1/permissions", permissionPayload); + var permissionFilters = PermissionFilters.WithSpecifications() + .WithName(permissionPayload.Name) + .Build(); + + Assert.Equal(HttpStatusCode.Created, permissionResponse.StatusCode); + + var permissions = await permissionCollection.GetPermissionsAsync(permissionFilters, CancellationToken.None); + var permission = permissions.FirstOrDefault(); + + Assert.NotEmpty(permissions); + Assert.NotNull(permission); + + /* act: send DELETE request for permission not assigned to client */ + var response = await httpClient.DeleteAsync($"api/v1/clients/{client.Id}/permissions/{permission.Id}"); + var error = await response.Content.ReadFromJsonAsync(); + + /* assert: response should be 409 Conflict */ + Assert.NotNull(error); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal(ClientErrors.PermissionNotAssigned, error); + } } From 3235572bdcca901d39d7fe6720a7afccb9eea258 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 17 Apr 2026 20:31:19 -0300 Subject: [PATCH 088/117] feature(#20): this commit restores the return of the client model after creation, instead of a response with no body --- .../Handlers/Client/ClientCreationHandler.cs | 8 ++++---- .../Payloads/Client/ClientCreationScheme.cs | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientCreationHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientCreationHandler.cs index ecab996..3851c27 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientCreationHandler.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientCreationHandler.cs @@ -1,9 +1,9 @@ namespace HttpsRichardy.Federation.Application.Handlers.Client; public sealed class ClientCreationHandler(IClientCredentialsGenerator credentialsGenerator, IRealmProvider realmProvider, IClientCollection clientCollection) : - IDispatchHandler + IDispatchHandler> { - public async Task HandleAsync(ClientCreationScheme parameters, CancellationToken cancellation = default) + public async Task> HandleAsync(ClientCreationScheme parameters, CancellationToken cancellation = default) { var filters = ClientFilters.WithSpecifications() .WithName(parameters.Name) @@ -14,7 +14,7 @@ public async Task HandleAsync(ClientCreationScheme parameters, Cancellat if (existingClient is not null) { - return Result.Failure(ClientErrors.ClientAlreadyExists); + return Result.Failure(ClientErrors.ClientAlreadyExists); } var realm = realmProvider.GetCurrentRealm(); @@ -24,6 +24,6 @@ public async Task HandleAsync(ClientCreationScheme parameters, Cancellat await clientCollection.InsertAsync(client, cancellation: cancellation); - return Result.Success(); + return Result.Success(client.AsResponse()); } } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientCreationScheme.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientCreationScheme.cs index 17e5967..0c39f29 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientCreationScheme.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientCreationScheme.cs @@ -1,9 +1,9 @@ namespace HttpsRichardy.Federation.Application.Payloads.Client; -public sealed record ClientCreationScheme : IDispatchable +public sealed record ClientCreationScheme : IDispatchable> { public string Name { get; init; } = default!; public IEnumerable Flows { get; init; } = []; public IEnumerable RedirectUris { get; init; } = []; -} \ No newline at end of file +} From dc40a5951cabf07fa7b792ee63dd795bc5a744c4 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 17 Apr 2026 20:32:05 -0300 Subject: [PATCH 089/117] feature(#20): this commit updates the API convention to include a sample response when creating a client --- .../Conventions/ClientsConventions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs index 147ad9f..4dd64d5 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs @@ -9,7 +9,7 @@ public static class ClientsConventions public static void GetClientsAsync(ClientsFetchParameters request, CancellationToken cancellation) { } [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] - [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ClientScheme), StatusCodes.Status201Created)] [ProducesResponseType(typeof(Error), StatusCodes.Status409Conflict)] public static void CreateClientAsync(ClientCreationScheme request, CancellationToken cancellation) { } From 95b72e0501339733f4d507a63a2dd43534b0b9b0 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 17 Apr 2026 20:34:14 -0300 Subject: [PATCH 090/117] feature(#20): this commit updates the client creation endpoint to return the creation result in the response body --- .../Controllers/ClientsController.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs index 9445fb8..f39c1f6 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs @@ -35,8 +35,8 @@ public async Task CreateClientAsync([FromBody] ClientCreationSche return result switch { - { IsSuccess: true } => - StatusCode(StatusCodes.Status201Created), + { IsSuccess: true } when result.Data is not null => + StatusCode(StatusCodes.Status201Created, result.Data), { IsFailure: true } when result.Error == ClientErrors.ClientAlreadyExists => StatusCode(StatusCodes.Status409Conflict, result.Error), From 442f74649122673189464d738e7a478fe410c0cf Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Fri, 17 Apr 2026 20:40:49 -0300 Subject: [PATCH 091/117] chore(#20): this commit removes unnecessary and irrelevant comments in the current context --- .../Controllers/ClientsController.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs index f39c1f6..3ceaf8d 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs @@ -30,9 +30,6 @@ public async Task CreateClientAsync([FromBody] ClientCreationSche { var result = await dispatcher.DispatchAsync(request, 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 } when result.Data is not null => From d679784cad48084be1963cf9f9c385fb5612e315 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Sat, 18 Apr 2026 02:06:16 -0300 Subject: [PATCH 092/117] refactor(#20): this commit removes client-id and client-secret from realm details record --- .../Source/Payloads/Realm/RealmDetails.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/Packages/Federation.Sdk.Contracts/Source/Payloads/Realm/RealmDetails.cs b/Packages/Federation.Sdk.Contracts/Source/Payloads/Realm/RealmDetails.cs index 1646913..42e3bb3 100644 --- a/Packages/Federation.Sdk.Contracts/Source/Payloads/Realm/RealmDetails.cs +++ b/Packages/Federation.Sdk.Contracts/Source/Payloads/Realm/RealmDetails.cs @@ -5,6 +5,4 @@ public sealed record RealmDetails public string Id { get; init; } = default!; public string Name { get; init; } = default!; public string? Description { get; init; } = default!; - public string ClientId { get; init; } = default!; - public string ClientSecret { get; init; } = default!; } \ No newline at end of file From 2653bbc352c8916d456bce006389c61d4cf0839f Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Sat, 18 Apr 2026 02:07:21 -0300 Subject: [PATCH 093/117] refactor(#20): this commit removes client-id property from realm fetch parameters record --- .../Source/Payloads/Realm/RealmFetchParameters.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/Packages/Federation.Sdk.Contracts/Source/Payloads/Realm/RealmFetchParameters.cs b/Packages/Federation.Sdk.Contracts/Source/Payloads/Realm/RealmFetchParameters.cs index 0c2226a..95b6125 100644 --- a/Packages/Federation.Sdk.Contracts/Source/Payloads/Realm/RealmFetchParameters.cs +++ b/Packages/Federation.Sdk.Contracts/Source/Payloads/Realm/RealmFetchParameters.cs @@ -3,9 +3,7 @@ namespace HttpsRichardy.Federation.Sdk.Contracts.Payloads.Realm; public sealed record RealmFetchParameters { public string? Id { get; init; } - public string? Name { get; init; } - public string? ClientId { get; init; } public bool? IncludeDeleted { get; init; } public int PageNumber { get; init; } = 1; From 10943d39d7161067c0b5209439f86a33c49584a1 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Sat, 18 Apr 2026 02:11:59 -0300 Subject: [PATCH 094/117] refactor(#20): this commit removes unnecessary pragma warning directive from program.cs --- .../Backend/Source/HttpsRichardy.Federation.WebApi/Program.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Program.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Program.cs index 40c415d..260eb04 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Program.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Program.cs @@ -1,5 +1,3 @@ -#pragma warning disable S1118 - namespace HttpsRichardy.Federation.WebApi; public partial class Program From 92cfbe714d4564bf6648ce19a352e91d2f43f78a Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Sat, 18 Apr 2026 02:19:04 -0300 Subject: [PATCH 095/117] feature(#20): enhance realm and client creation logic in bootstrapper extension --- .../Extensions/BootstrapperExtension.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs index 3cb6eb3..b5b7109 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs @@ -34,6 +34,11 @@ public static async Task UseBootstrapperAsync(this IApplicationBuilder builder) return; } + await realmCollection.InsertAsync(defaultRealm); + + realms = await realmCollection.GetRealmsAsync(realmFilters, cancellation: default); + defaultRealm = realms.FirstOrDefault() ?? throw new InvalidOperationException("Unable to load the default realm after creation."); + defaultRealm.Permissions = [.. RealmPermissions.SystemPermissions.Select(permissionName => new Permission { Id = Identifier.Generate(), @@ -41,14 +46,15 @@ public static async Task UseBootstrapperAsync(this IApplicationBuilder builder) RealmId = defaultRealm.Id })]; - defaultClient.Secret = await passwordHasher.HashPasswordAsync(clientCredentials.ClientId + defaultClient.Name); + defaultClient.ClientId = clientCredentials.ClientId; + defaultClient.RealmId = defaultRealm.Id; + defaultClient.Secret = clientCredentials.ClientSecret; defaultClient.Permissions = [.. defaultRealm.Permissions]; defaultRealm.Clients = [defaultClient]; realmProvider.SetRealm(defaultRealm); - await realmCollection.InsertAsync(defaultRealm); await permissionCollection.InsertManyAsync(defaultRealm.Permissions); await clientCollection.InsertAsync(defaultClient); From 3fada7feff567a0b6ccf169e91468d0a1d050ce0 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 13:14:24 -0300 Subject: [PATCH 096/117] feature(#20): this commit introduces support for assigning audiences to customers --- .../Client/AssignAudienceClientHandler.cs | 33 +++++++++++++++++++ .../Client/AssignClientAudienceScheme.cs | 9 +++++ .../Aggregates/Client.cs | 1 + .../Concepts/Audience.cs | 7 ++++ .../Errors/ClientErrors.cs | 5 +++ 5 files changed, 55 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/AssignAudienceClientHandler.cs create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/AssignClientAudienceScheme.cs create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Domain/Concepts/Audience.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/AssignAudienceClientHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/AssignAudienceClientHandler.cs new file mode 100644 index 0000000..4dce530 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/AssignAudienceClientHandler.cs @@ -0,0 +1,33 @@ +namespace HttpsRichardy.Federation.Application.Handlers.Client; + +public sealed class AssignAudienceClientHandler(IClientCollection clientCollection) : + IDispatchHandler>> +{ + public async Task>> HandleAsync( + AssignClientAudienceScheme parameters, CancellationToken cancellation = default) + { + var filters = ClientFilters.WithSpecifications() + .WithIdentifier(parameters.Id) + .Build(); + + var clients = await clientCollection.GetClientsAsync(filters, cancellation); + var client = clients.FirstOrDefault(); + + if (client is null) + { + return Result>.Failure(ClientErrors.ClientDoesNotExist); + } + + var audience = new Audience(parameters.Value); + if (client.Audiences.Any(current => current.Value == audience.Value)) + { + return Result>.Failure(ClientErrors.ClientAlreadyHasAudience); + } + + client.Audiences.Add(audience); + + await clientCollection.UpdateAsync(client, cancellation); + + return Result>.Success([.. client.Audiences.Select(current => current.Value)]); + } +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/AssignClientAudienceScheme.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/AssignClientAudienceScheme.cs new file mode 100644 index 0000000..0122709 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/AssignClientAudienceScheme.cs @@ -0,0 +1,9 @@ +namespace HttpsRichardy.Federation.Application.Payloads.Client; + +public sealed record AssignClientAudienceScheme : + IDispatchable>> +{ + [JsonIgnore] + public string Id { get; init; } = default!; + public string Value { get; init; } = default!; +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs index 1009f07..7631f41 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs @@ -8,6 +8,7 @@ public sealed class Client : Aggregate public string RealmId { get; set; } = default!; public string ClientId { get; set; } = default!; + public ICollection Audiences { get; set; } = []; public ICollection Flows { get; set; } = []; public ICollection RedirectUris { get; set; } = []; public ICollection Permissions { get; set; } = []; diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Concepts/Audience.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Concepts/Audience.cs new file mode 100644 index 0000000..269a563 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Concepts/Audience.cs @@ -0,0 +1,7 @@ +namespace HttpsRichardy.Federation.Domain.Concepts; + +public sealed record Audience(string Value) : + IValueObject +{ + public string Value { get; init; } = Value; +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs index d3c2c9f..cceee30 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs @@ -12,6 +12,11 @@ public static class ClientErrors Description: "The client already has the specified permission assigned." ); + public static readonly Error ClientAlreadyHasAudience = new( + Code: "#ERROR-F4E2A", + Description: "The client already has the specified audience assigned." + ); + public static readonly Error PermissionNotAssigned = new( Code: "#ERROR-C2FB0", Description: "The client does not have the specified permission assigned." From af8b5fef9d10da10b010f13bb3c51c49edebb7fb Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 13:22:42 -0300 Subject: [PATCH 097/117] feature(#20): this commit introduces a new POST action that allows you to associate an audience with a client via the /{id}/audiences route. It includes permission-based authorization and returns appropriate status codes for success, a non-existent client, or an audience that is already associated. --- .../Controllers/ClientsController.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs index 3ceaf8d..549e374 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs @@ -141,4 +141,24 @@ public async Task RevokePermissionAsync([FromRoute] string id, [F StatusCode(StatusCodes.Status409Conflict, result.Error) }; } + + [HttpPost("{id}/audiences")] + [Stability(Stability.Stable)] + [Authorize(Roles = Permissions.EditClient)] + public async Task AssignAudienceAsync([FromRoute] string id, [FromBody] AssignClientAudienceScheme request, CancellationToken cancellation) + { + var result = await dispatcher.DispatchAsync(request with { Id = id }, cancellation); + + return result switch + { + { IsSuccess: true } when result.Data is not null => + StatusCode(StatusCodes.Status200OK, result.Data), + + { IsFailure: true } when result.Error == ClientErrors.ClientDoesNotExist => + StatusCode(StatusCodes.Status404NotFound, result.Error), + + { IsFailure: true } when result.Error == ClientErrors.ClientAlreadyHasAudience => + StatusCode(StatusCodes.Status409Conflict, result.Error), + }; + } } From 93831fc4b668874faf25cc05f31e1e1162cb6cf3 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 13:23:43 -0300 Subject: [PATCH 098/117] feature(#20): this commit introduces an API convention to improve the API specification/reference --- .../Conventions/ClientsConventions.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs index 4dd64d5..3bc737a 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs @@ -40,4 +40,10 @@ public static void AssignPermissionAsync(string id, AssignClientPermissionScheme [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(Error), StatusCodes.Status409Conflict)] public static void RevokePermissionAsync(string id, string permissionId, CancellationToken cancellation) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + [ProducesResponseType(typeof(IReadOnlyCollection), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(Error), StatusCodes.Status409Conflict)] + public static void AssignAudienceAsync(string id, AssignClientAudienceScheme request, CancellationToken cancellation) { } } From 0bfa659ec019492d09f442c97c293f94f208638c Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 13:31:15 -0300 Subject: [PATCH 099/117] feature(#20): this commit introduces end-to-end tests for POST /clients/{id}/audiences --- .../Endpoints/ClientEndpointTests.cs | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs index 3f41b49..1f1021b 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs @@ -760,4 +760,166 @@ public async Task WhenDeleteClientPermissionWithPermissionNotAssigned_ShouldRetu Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); Assert.Equal(ClientErrors.PermissionNotAssigned, error); } + + [Fact(DisplayName = "[e2e] - when POST /clients/{id}/audiences with valid audience should assign audience successfully")] + public async Task WhenPostClientAudiencesWithValidAudience_ShouldAssignAudienceSuccessfully() + { + /* arrange: resolve required dependencies */ + var clientCollection = factory.Services.GetRequiredService(); + + /* 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 client */ + var clientPayload = _fixture.Build() + .With(client => client.Name, $"test-client-{Guid.NewGuid()}") + .With(client => client.Flows, [Grant.ClientCredentials]) + .With(client => client.RedirectUris, []) + .Create(); + + var clientResponse = await httpClient.PostAsJsonAsync("api/v1/clients", clientPayload); + + Assert.Equal(HttpStatusCode.Created, clientResponse.StatusCode); + + var clientFilters = ClientFilters.WithSpecifications() + .WithName(clientPayload.Name) + .Build(); + + var clients = await clientCollection.GetClientsAsync(clientFilters, CancellationToken.None); + var client = clients.FirstOrDefault(); + + Assert.NotEmpty(clients); + Assert.NotNull(client); + + /* arrange: prepare request to assign audience to client */ + var payload = new AssignClientAudienceScheme + { + Value = "https://api.example.com" + }; + + /* act: send POST request to assign audience to client */ + var response = await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", payload); + var audiences = await response.Content.ReadFromJsonAsync>(); + + /* assert: response should be 200 OK and audiences list should be returned */ + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(audiences); + + /* assert: the assigned audience should be in the returned list */ + Assert.Contains(audiences, current => current == payload.Value); + } + + [Fact(DisplayName = "[e2e] - when POST /clients/{id}/audiences with duplicate audience should return 409 #ERROR-F4E2A")] + public async Task WhenPostClientAudiencesWithDuplicateAudience_ShouldReturnConflict() + { + /* arrange: resolve required dependencies */ + var clientCollection = factory.Services.GetRequiredService(); + + /* 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 client */ + var payload = _fixture.Build() + .With(client => client.Name, $"test-client-{Guid.NewGuid()}") + .With(client => client.Flows, [Grant.ClientCredentials]) + .With(client => client.RedirectUris, []) + .Create(); + + var clientResponse = await httpClient.PostAsJsonAsync("api/v1/clients", payload); + + Assert.Equal(HttpStatusCode.Created, clientResponse.StatusCode); + + var clientFilters = ClientFilters.WithSpecifications() + .WithName(payload.Name) + .Build(); + + var clients = await clientCollection.GetClientsAsync(clientFilters, CancellationToken.None); + var client = clients.FirstOrDefault(); + + Assert.NotEmpty(clients); + Assert.NotNull(client); + + /* arrange: assign audience to client first time */ + var content = new AssignClientAudienceScheme + { + Value = "https://api.example.com" + }; + + var firstResponse = await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", content); + + Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); + + /* act: attempt to assign the same audience again */ + var secondResponse = await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", content); + var error = await secondResponse.Content.ReadFromJsonAsync(); + + /* assert: response should be 409 Conflict */ + Assert.NotNull(error); + + Assert.Equal(HttpStatusCode.Conflict, secondResponse.StatusCode); + Assert.Equal(ClientErrors.ClientAlreadyHasAudience, error); + } + + [Fact(DisplayName = "[e2e] - when POST /clients/{id}/audiences with non-existent client should return 404 #ERROR-2D943")] + public async Task WhenPostClientAudiencesWithNonExistentClient_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 client ID */ + var nonExistentClientId = Guid.NewGuid().ToString(); + var payload = new AssignClientAudienceScheme + { + Value = "https://api.example.com" + }; + + /* act: send POST request for non-existent client */ + var response = await httpClient.PostAsJsonAsync($"api/v1/clients/{nonExistentClientId}/audiences", payload); + var error = await response.Content.ReadFromJsonAsync(); + + /* assert: response should be 404 Not Found */ + Assert.NotNull(error); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(ClientErrors.ClientDoesNotExist, error); + } } From fdf39114af17b2e0532d81582078a0e56e9c98ec Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 13:33:06 -0300 Subject: [PATCH 100/117] feature(#20): this commit renames the test client --- .../Backend/Tests/Integration/Endpoints/ConnectEndpointTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Applications/Backend/Tests/Integration/Endpoints/ConnectEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/ConnectEndpointTests.cs index 73c2203..e9e1e9f 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/ConnectEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/ConnectEndpointTests.cs @@ -117,7 +117,7 @@ public async Task WhenPostTokenWithInvalidClientSecret_ShouldReturnUnauthorized( /* arrange: create a client */ var payload = _fixture.Build() - .With(client => client.Name, "admin") + .With(client => client.Name, "nexus") .With(client => client.Flows, [Grant.ClientCredentials]) .With(client => client.RedirectUris, []) .Create(); From 226a04db49f7c28bb5016ee023e6d3c838d043fa Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 13:56:33 -0300 Subject: [PATCH 101/117] feature(#20): this commit introduces support for multiple audiences --- .../Security/ClaimsBuilder.cs | 10 ++++++++++ .../Security/JwtSecurityTokenService.cs | 2 ++ 2 files changed, 12 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/ClaimsBuilder.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/ClaimsBuilder.cs index 3273d4c..e6550bf 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/ClaimsBuilder.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/ClaimsBuilder.cs @@ -39,6 +39,16 @@ public ClaimsBuilder WithPermissions(IEnumerable permissions) return this; } + public ClaimsBuilder WithAudiences(IEnumerable audiences) + { + foreach (var audience in audiences) + { + _claims.Add(new Claim(JwtRegisteredClaimNames.Aud, audience)); + } + + return this; + } + public ClaimsBuilder WithClaim(string type, string value) { _claims.Add(new Claim(type, value)); diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs index 2916c55..22ee8e1 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs @@ -71,9 +71,11 @@ public async Task> GenerateAccessTokenAsync(Client client, var realm = realmProvider.GetCurrentRealm(); var claims = new ClaimsBuilder() + .WithSubject(client.Id) .WithRealmName(realm.Name) .WithClientId(client.Id) .WithPermissions(client.Permissions) + .WithAudiences(client.Audiences.Select(audience => audience.Value)) .Build(); var privateKey = await GetPrivateKeyAsync(cancellation); From 49b0c92392e3ec20d74369ba076bdeb3cc8b4fe3 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 14:31:07 -0300 Subject: [PATCH 102/117] feature(#20): this commit introduces end-to-end tests for a client's audience removal endpoint --- .../Endpoints/ClientEndpointTests.cs | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) diff --git a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs index 1f1021b..08b8b75 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs @@ -922,4 +922,269 @@ public async Task WhenPostClientAudiencesWithNonExistentClient_ShouldReturnNotFo Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Equal(ClientErrors.ClientDoesNotExist, error); } + + [Fact(DisplayName = "[e2e] - when POST /clients/{id}/audiences and generating token should include audiences in JWT")] + public async Task WhenAssigningAudiencesAndGeneratingToken_ShouldIncludeAudiencesInJwt() + { + /* arrange: resolve required dependencies */ + var clientCollection = factory.Services.GetRequiredService(); + + /* 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 client */ + var clientPayload = _fixture.Build() + .With(client => client.Name, $"test-client-{Guid.NewGuid()}") + .With(client => client.Flows, [Grant.ClientCredentials]) + .With(client => client.RedirectUris, []) + .Create(); + + var clientResponse = await httpClient.PostAsJsonAsync("api/v1/clients", clientPayload); + + Assert.Equal(HttpStatusCode.Created, clientResponse.StatusCode); + + var clientFilters = ClientFilters.WithSpecifications() + .WithName(clientPayload.Name) + .Build(); + + var clients = await clientCollection.GetClientsAsync(clientFilters, CancellationToken.None); + var client = clients.FirstOrDefault(); + + Assert.NotEmpty(clients); + Assert.NotNull(client); + + /* arrange: assign first audience */ + var audience1 = new AssignClientAudienceScheme + { + Value = "https://api1.example.com" + }; + + var audience1Response = await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", audience1); + + Assert.NotNull(audience1Response); + Assert.Equal(HttpStatusCode.OK, audience1Response.StatusCode); + + /* arrange: assign second audience */ + var audience2 = new AssignClientAudienceScheme + { + Value = "https://api2.example.com" + }; + + var audience2Response = await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", audience2); + + Assert.NotNull(audience2Response); + Assert.Equal(HttpStatusCode.OK, audience2Response.StatusCode); + + /* arrange: prepare client credentials for token generation */ + var connectClient = factory.HttpClient; + var clientCredentials = new Dictionary + { + { "grant_type", "client_credentials" }, + { "client_id", client.ClientId }, + { "client_secret", client.Secret } + }; + + var content = new FormUrlEncodedContent(clientCredentials); + + /* act: generate token via client_credentials */ + var tokenResponse = await connectClient.PostAsync("api/v1/protocol/open-id/connect/token", content); + var tokenResult = await tokenResponse.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.OK, tokenResponse.StatusCode); + + Assert.NotNull(tokenResult); + Assert.NotEmpty(tokenResult.AccessToken); + + /* act: decode JWT to verify audiences */ + var handler = new JwtSecurityTokenHandler(); + var token = handler.ReadJwtToken(tokenResult.AccessToken); + + /* assert: token should contain both audiences in the 'aud' claim */ + var audienceClaims = token.Claims + .Where(claim => claim.Type == JwtRegisteredClaimNames.Aud) + .Select(claim => claim.Value) + .ToList(); + + Assert.NotNull(audienceClaims); + Assert.NotEmpty(audienceClaims); + + Assert.Contains(audience1.Value, audienceClaims); + Assert.Contains(audience2.Value, audienceClaims); + } + + [Fact(DisplayName = "[e2e] - when DELETE /clients/{id}/audiences/{audience} should revoke audience successfully")] + public async Task WhenDeleteClientAudience_ShouldRevokeAudienceSuccessfully() + { + /* arrange: resolve required dependencies */ + var clientCollection = factory.Services.GetRequiredService(); + + /* 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 client */ + var clientPayload = _fixture.Build() + .With(client => client.Name, $"test-client-{Guid.NewGuid()}") + .With(client => client.Flows, [Grant.ClientCredentials]) + .With(client => client.RedirectUris, []) + .Create(); + + var clientResponse = await httpClient.PostAsJsonAsync("api/v1/clients", clientPayload); + + Assert.Equal(HttpStatusCode.Created, clientResponse.StatusCode); + + var clientFilters = ClientFilters.WithSpecifications() + .WithName(clientPayload.Name) + .Build(); + + var clients = await clientCollection.GetClientsAsync(clientFilters, CancellationToken.None); + var client = clients.FirstOrDefault(); + + Assert.NotEmpty(clients); + Assert.NotNull(client); + + /* arrange: assign two audiences */ + var audience1 = "https://api1.example.com"; + var audience2 = "https://api2.example.com"; + + var audience1Payload = new AssignClientAudienceScheme { Value = audience1 }; + var audience2Payload = new AssignClientAudienceScheme { Value = audience2 }; + + await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", audience1Payload); + await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", audience2Payload); + + /* act: send DELETE request to revoke first audience */ + var response = await httpClient.DeleteAsync($"api/v1/clients/{client.Id}/audiences/{Uri.EscapeDataString(audience1)}"); + var remainingAudiences = await response.Content.ReadFromJsonAsync>(); + + /* assert: response should be 200 OK */ + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(remainingAudiences); + + /* assert: only second audience should remain */ + Assert.Single(remainingAudiences); + Assert.Contains(audience2, remainingAudiences); + Assert.DoesNotContain(audience1, remainingAudiences); + } + + [Fact(DisplayName = "[e2e] - when DELETE /clients/{id}/audiences/{audience} with non-existent client should return 404 #ERROR-2D943")] + public async Task WhenDeleteClientAudienceWithNonExistentClient_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 non-existent client ID */ + var nonExistentClientId = Guid.NewGuid().ToString(); + var audience = "https://api.example.com"; + + /* act: send DELETE request for non-existent client */ + var response = await httpClient.DeleteAsync($"api/v1/clients/{nonExistentClientId}/audiences/{Uri.EscapeDataString(audience)}"); + var error = await response.Content.ReadFromJsonAsync(); + + /* assert: response should be 404 Not Found */ + Assert.NotNull(error); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(ClientErrors.ClientDoesNotExist, error); + } + + [Fact(DisplayName = "[e2e] - when DELETE /clients/{id}/audiences/{audience} with audience not assigned should return 409 #ERROR-B3F8E")] + public async Task WhenDeleteClientAudienceWithAudienceNotAssigned_ShouldReturnConflict() + { + /* arrange: resolve required dependencies */ + var clientCollection = factory.Services.GetRequiredService(); + + /* 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 client */ + var clientPayload = _fixture.Build() + .With(client => client.Name, $"test-client-{Guid.NewGuid()}") + .With(client => client.Flows, [Grant.ClientCredentials]) + .With(client => client.RedirectUris, []) + .Create(); + + var clientResponse = await httpClient.PostAsJsonAsync("api/v1/clients", clientPayload); + + Assert.Equal(HttpStatusCode.Created, clientResponse.StatusCode); + + var clientFilters = ClientFilters.WithSpecifications() + .WithName(clientPayload.Name) + .Build(); + + var clients = await clientCollection.GetClientsAsync(clientFilters, CancellationToken.None); + var client = clients.FirstOrDefault(); + + Assert.NotEmpty(clients); + Assert.NotNull(client); + + /* arrange: assign one audience */ + var assignedAudience = "https://api1.example.com"; + var assignPayload = new AssignClientAudienceScheme { Value = assignedAudience }; + + await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", assignPayload); + + /* act: send DELETE request for non-assigned audience */ + var nonAssignedAudience = "https://api2.example.com"; + + var response = await httpClient.DeleteAsync($"api/v1/clients/{client.Id}/audiences/{Uri.EscapeDataString(nonAssignedAudience)}"); + var error = await response.Content.ReadFromJsonAsync(); + + /* assert: response should be 409 Conflict */ + Assert.NotNull(error); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal(ClientErrors.AudienceNotAssigned, error); + } } From 7e7b4f66ae02c4a1288f039670687a025a9f28b3 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 14:32:35 -0300 Subject: [PATCH 103/117] feature(#20): this commit introduces a handler to revoke a client's audience --- .../Client/RevokeAudienceFromClientHandler.cs | 33 +++++++++++++++++++ .../Client/RevokeClientAudienceScheme.cs | 11 +++++++ 2 files changed, 44 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/RevokeAudienceFromClientHandler.cs create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/RevokeClientAudienceScheme.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/RevokeAudienceFromClientHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/RevokeAudienceFromClientHandler.cs new file mode 100644 index 0000000..525f6aa --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/RevokeAudienceFromClientHandler.cs @@ -0,0 +1,33 @@ +namespace HttpsRichardy.Federation.Application.Handlers.Client; + +public sealed class RevokeAudienceFromClientHandler(IClientCollection clientCollection) : + IDispatchHandler>> +{ + public async Task>> HandleAsync( + RevokeClientAudienceScheme parameters, CancellationToken cancellation = default) + { + var filters = ClientFilters.WithSpecifications() + .WithIdentifier(parameters.Id) + .Build(); + + var clients = await clientCollection.GetClientsAsync(filters, cancellation); + var client = clients.FirstOrDefault(); + + if (client is null) + { + return Result>.Failure(ClientErrors.ClientDoesNotExist); + } + + var audienceToRemove = client.Audiences.FirstOrDefault(current => current.Value == parameters.Audience); + if (audienceToRemove is null) + { + return Result>.Failure(ClientErrors.AudienceNotAssigned); + } + + client.Audiences.Remove(audienceToRemove); + + await clientCollection.UpdateAsync(client, cancellation); + + return Result>.Success([.. client.Audiences.Select(current => current.Value)]); + } +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/RevokeClientAudienceScheme.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/RevokeClientAudienceScheme.cs new file mode 100644 index 0000000..e3b3400 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/RevokeClientAudienceScheme.cs @@ -0,0 +1,11 @@ +namespace HttpsRichardy.Federation.Application.Payloads.Client; + +public sealed record RevokeClientAudienceScheme : + IDispatchable>> +{ + [JsonIgnore] + public string Id { get; init; } = default!; + + [JsonIgnore] + public string Audience { get; init; } = default!; +} From f59abdfe9a4f9881384efcbe50921bb8579314dd Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 14:33:26 -0300 Subject: [PATCH 104/117] feature(#20): this commit introduces an audience association schema validator for clients --- .../Client/AssignClientAudienceValidator.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Client/AssignClientAudienceValidator.cs diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Client/AssignClientAudienceValidator.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Client/AssignClientAudienceValidator.cs new file mode 100644 index 0000000..e7cc01e --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Validators/Client/AssignClientAudienceValidator.cs @@ -0,0 +1,13 @@ +namespace HttpsRichardy.Federation.Application.Validators.Client; + +public sealed class AssignClientAudienceValidator : AbstractValidator +{ + public AssignClientAudienceValidator() + { + RuleFor(scheme => scheme.Value) + .NotEmpty() + .WithMessage("audience value is required.") + .MaximumLength(500) + .WithMessage("audience value cannot exceed 500 characters."); + } +} From 0c1f7eb4f14fc22cb385263aa0e77c68ce290f06 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 14:34:08 -0300 Subject: [PATCH 105/117] feature(#20): this commit introduces a new error class related to clients --- .../HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs index cceee30..48fed34 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs @@ -17,6 +17,11 @@ public static class ClientErrors Description: "The client already has the specified audience assigned." ); + public static readonly Error AudienceNotAssigned = new( + Code: "#ERROR-B3F8E", + Description: "The client does not have the specified audience assigned." + ); + public static readonly Error PermissionNotAssigned = new( Code: "#ERROR-C2FB0", Description: "The client does not have the specified permission assigned." From ec6459e3753daa5ffb3e54c5f0b1677b3b0802c9 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 14:34:52 -0300 Subject: [PATCH 106/117] feature(#20): this commit introduces an endpoint to revoke a client's audience access --- .../Controllers/ClientsController.cs | 24 +++++++++++++++++++ .../Conventions/ClientsConventions.cs | 7 ++++++ 2 files changed, 31 insertions(+) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs index 549e374..df343c7 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs @@ -159,6 +159,30 @@ public async Task AssignAudienceAsync([FromRoute] string id, [Fro { IsFailure: true } when result.Error == ClientErrors.ClientAlreadyHasAudience => StatusCode(StatusCodes.Status409Conflict, result.Error), + + { IsFailure: true } when result.Error == ClientErrors.InvalidAudienceValue => + StatusCode(StatusCodes.Status400BadRequest, result.Error), + }; + } + + [HttpDelete("{id}/audiences/{audience}")] + [Stability(Stability.Stable)] + [Authorize(Roles = Permissions.EditClient)] + public async Task RevokeAudienceAsync([FromRoute] string id, [FromRoute] string audience, CancellationToken cancellation) + { + var request = new RevokeClientAudienceScheme { Id = id, Audience = audience }; + var result = await dispatcher.DispatchAsync(request, cancellation); + + return result switch + { + { IsSuccess: true } when result.Data is not null => + StatusCode(StatusCodes.Status200OK, result.Data), + + { IsFailure: true } when result.Error == ClientErrors.ClientDoesNotExist => + StatusCode(StatusCodes.Status404NotFound, result.Error), + + { IsFailure: true } when result.Error == ClientErrors.AudienceNotAssigned => + StatusCode(StatusCodes.Status409Conflict, result.Error) }; } } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs index 3bc737a..c6bf3f9 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs @@ -46,4 +46,11 @@ public static void RevokePermissionAsync(string id, string permissionId, Cancell [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(Error), StatusCodes.Status409Conflict)] public static void AssignAudienceAsync(string id, AssignClientAudienceScheme request, CancellationToken cancellation) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + [ProducesResponseType(typeof(IReadOnlyCollection), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(Error), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(Error), StatusCodes.Status409Conflict)] + public static void RevokeAudienceAsync(string id, string audience, CancellationToken cancellation) { } } From 32a23356bfd1648127b887e8c2c60bee59f7c84d Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 14:42:38 -0300 Subject: [PATCH 107/117] =?UTF-8?q?feature(#20):=20this=20commit=20removes?= =?UTF-8?q?=20error=20handling=20for=20=E2=80=9CInvalidAudienceValue?= =?UTF-8?q?=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/ClientsController.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs index df343c7..906dc9e 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs @@ -158,10 +158,7 @@ public async Task AssignAudienceAsync([FromRoute] string id, [Fro StatusCode(StatusCodes.Status404NotFound, result.Error), { IsFailure: true } when result.Error == ClientErrors.ClientAlreadyHasAudience => - StatusCode(StatusCodes.Status409Conflict, result.Error), - - { IsFailure: true } when result.Error == ClientErrors.InvalidAudienceValue => - StatusCode(StatusCodes.Status400BadRequest, result.Error), + StatusCode(StatusCodes.Status409Conflict, result.Error) }; } From 0b901ca81b15dee291e1c7173d2cfe53b92adb8c Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 14:56:59 -0300 Subject: [PATCH 108/117] feature(#20): this commit updates listener tests to use simple names --- .../Integration/Endpoints/ClientEndpointTests.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs index 08b8b75..abbd6b9 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs @@ -1069,17 +1069,19 @@ public async Task WhenDeleteClientAudience_ShouldRevokeAudienceSuccessfully() Assert.NotNull(client); /* arrange: assign two audiences */ - var audience1 = "https://api1.example.com"; - var audience2 = "https://api2.example.com"; + var audience1 = "orion"; + var audience2 = "sirius"; var audience1Payload = new AssignClientAudienceScheme { Value = audience1 }; var audience2Payload = new AssignClientAudienceScheme { Value = audience2 }; - await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", audience1Payload); - await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", audience2Payload); + var x = await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", audience1Payload); + var y = await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", audience2Payload); /* act: send DELETE request to revoke first audience */ - var response = await httpClient.DeleteAsync($"api/v1/clients/{client.Id}/audiences/{Uri.EscapeDataString(audience1)}"); + var response = await httpClient.DeleteAsync($"api/v1/clients/{client.Id}/audiences/{audience1}"); + var contentString = await response.Content.ReadAsStringAsync(); + var remainingAudiences = await response.Content.ReadFromJsonAsync>(); /* assert: response should be 200 OK */ @@ -1088,6 +1090,7 @@ public async Task WhenDeleteClientAudience_ShouldRevokeAudienceSuccessfully() /* assert: only second audience should remain */ Assert.Single(remainingAudiences); + Assert.Contains(audience2, remainingAudiences); Assert.DoesNotContain(audience1, remainingAudiences); } From d684d6a5cb1888ede7567cd8a36127cd30545320 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 14:58:40 -0300 Subject: [PATCH 109/117] chore(#20): this commit removes temporary debug variables --- .../Tests/Integration/Endpoints/ClientEndpointTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs index abbd6b9..467a4f1 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs @@ -1075,8 +1075,8 @@ public async Task WhenDeleteClientAudience_ShouldRevokeAudienceSuccessfully() var audience1Payload = new AssignClientAudienceScheme { Value = audience1 }; var audience2Payload = new AssignClientAudienceScheme { Value = audience2 }; - var x = await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", audience1Payload); - var y = await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", audience2Payload); + await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", audience1Payload); + await httpClient.PostAsJsonAsync($"api/v1/clients/{client.Id}/audiences", audience2Payload); /* act: send DELETE request to revoke first audience */ var response = await httpClient.DeleteAsync($"api/v1/clients/{client.Id}/audiences/{audience1}"); From 8ceda53b8d6d55f590911f7ff0670f86d49d8cad Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 15:04:19 -0300 Subject: [PATCH 110/117] feature(#20): this commit removes unnecessary reading of the response content --- .../Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs index 467a4f1..533202f 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs @@ -1080,8 +1080,6 @@ public async Task WhenDeleteClientAudience_ShouldRevokeAudienceSuccessfully() /* act: send DELETE request to revoke first audience */ var response = await httpClient.DeleteAsync($"api/v1/clients/{client.Id}/audiences/{audience1}"); - var contentString = await response.Content.ReadAsStringAsync(); - var remainingAudiences = await response.Content.ReadFromJsonAsync>(); /* assert: response should be 200 OK */ From 7c043ec5c3195e1a6d7ecd5f4d16a3c90df6db4b Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 22:05:04 -0300 Subject: [PATCH 111/117] feature(#20): this commit updates global usings to support microsoft.identitymodel.tokens --- Packages/Federation.Sdk/Source/Usings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/Federation.Sdk/Source/Usings.cs b/Packages/Federation.Sdk/Source/Usings.cs index 4acd903..c68438a 100644 --- a/Packages/Federation.Sdk/Source/Usings.cs +++ b/Packages/Federation.Sdk/Source/Usings.cs @@ -4,11 +4,11 @@ global using System.Net.Http.Json; global using System.Reflection; +global using Microsoft.IdentityModel.Tokens; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.AspNetCore.Authentication.JwtBearer; global using HttpsRichardy.Internal.Essentials.Patterns; - global using HttpsRichardy.Federation.Sdk.Serializers; global using HttpsRichardy.Federation.Sdk.Helpers; global using HttpsRichardy.Federation.Sdk.Clients; From e850ca8cb1009f320375adfafd70f75464c19b6c Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 22:06:35 -0300 Subject: [PATCH 112/117] feature(#20): support for multiple audiences in JWT authentication with validation of multiple audiences. --- .../Source/Configurations/FederationOptions.cs | 3 ++- .../Source/Extensions/AuthenticationExtension.cs | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Packages/Federation.Sdk/Source/Configurations/FederationOptions.cs b/Packages/Federation.Sdk/Source/Configurations/FederationOptions.cs index 3525811..f965da9 100644 --- a/Packages/Federation.Sdk/Source/Configurations/FederationOptions.cs +++ b/Packages/Federation.Sdk/Source/Configurations/FederationOptions.cs @@ -6,4 +6,5 @@ public sealed record FederationOptions public string ClientSecret { get; set; } = default!; public string Realm { get; set; } = default!; public string Authority { get; set; } = default!; -} \ No newline at end of file + public string[] Audiences { get; set; } = []; +} diff --git a/Packages/Federation.Sdk/Source/Extensions/AuthenticationExtension.cs b/Packages/Federation.Sdk/Source/Extensions/AuthenticationExtension.cs index 543e444..dc8dee3 100644 --- a/Packages/Federation.Sdk/Source/Extensions/AuthenticationExtension.cs +++ b/Packages/Federation.Sdk/Source/Extensions/AuthenticationExtension.cs @@ -11,8 +11,17 @@ public static void AddBearerAuthentication(this IServiceCollection services) .AddJwtBearer(configuration => { configuration.Authority = options.Authority; - configuration.Audience = options.Realm; configuration.RequireHttpsMetadata = false; + configuration.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + + // https://www.rfc-editor.org/rfc/rfc7519.html?#section-4.1.3 + // supports multiple audiences in the "aud" claim, so we need to check if any of the audiences match + ValidIssuer = options.Authority, + ValidAudiences = options.Audiences + }; }); } -} \ No newline at end of file +} From 7c82ffae9460f161c105e86414697b869296caba Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 23:12:06 -0300 Subject: [PATCH 113/117] feature(#20): this commit introduces new routes in ocelot.json for client endpoints, including GET, POST, PUT, and DELETE methods. Each route has rate-limiting settings (5 requests per second per client) and QoS options (circuit breaker and timeout). All routes point to the downstream service. --- Applications/Proxy/Source/ocelot.json | 138 ++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/Applications/Proxy/Source/ocelot.json b/Applications/Proxy/Source/ocelot.json index c0afd99..c4212a7 100644 --- a/Applications/Proxy/Source/ocelot.json +++ b/Applications/Proxy/Source/ocelot.json @@ -551,6 +551,144 @@ "DurationOfBreak": 5000, "TimeoutValue": 25000 } + }, + { + "UpstreamHttpMethod": [ "GET", "POST" ], + "UpstreamPathTemplate": "/api/v1/clients", + "DownstreamPathTemplate": "/api/v1/clients", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 5286 + } + ], + "RateLimitOptions": { + "ClientIdHeader": "Client", + "EnableRateLimiting": true, + "Period": "1s", + "Limit": 5 + }, + "QoSOptions": { + "ExceptionsAllowedBeforeBreaking": 3, + "DurationOfBreak": 5000, + "TimeoutValue": 25000 + } + }, + { + "UpstreamHttpMethod": [ "PUT", "DELETE" ], + "UpstreamPathTemplate": "/api/v1/clients/{id}", + "DownstreamPathTemplate": "/api/v1/clients/{id}", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 5286 + } + ], + "RateLimitOptions": { + "ClientIdHeader": "Client", + "EnableRateLimiting": true, + "Period": "1s", + "Limit": 5 + }, + "QoSOptions": { + "ExceptionsAllowedBeforeBreaking": 3, + "DurationOfBreak": 5000, + "TimeoutValue": 25000 + } + }, + { + "UpstreamHttpMethod": [ "GET", "POST" ], + "UpstreamPathTemplate": "/api/v1/clients/{id}/permissions", + "DownstreamPathTemplate": "/api/v1/clients/{id}/permissions", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 5286 + } + ], + "RateLimitOptions": { + "ClientIdHeader": "Client", + "EnableRateLimiting": true, + "Period": "1s", + "Limit": 5 + }, + "QoSOptions": { + "ExceptionsAllowedBeforeBreaking": 3, + "DurationOfBreak": 5000, + "TimeoutValue": 25000 + } + }, + { + "UpstreamHttpMethod": [ "DELETE" ], + "UpstreamPathTemplate": "/api/v1/clients/{id}/permissions/{permissionId}", + "DownstreamPathTemplate": "/api/v1/clients/{id}/permissions/{permissionId}", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 5286 + } + ], + "RateLimitOptions": { + "ClientIdHeader": "Client", + "EnableRateLimiting": true, + "Period": "1s", + "Limit": 5 + }, + "QoSOptions": { + "ExceptionsAllowedBeforeBreaking": 3, + "DurationOfBreak": 5000, + "TimeoutValue": 25000 + } + }, + { + "UpstreamHttpMethod": [ "POST" ], + "UpstreamPathTemplate": "/api/v1/clients/{id}/audiences", + "DownstreamPathTemplate": "/api/v1/clients/{id}/audiences", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 5286 + } + ], + "RateLimitOptions": { + "ClientIdHeader": "Client", + "EnableRateLimiting": true, + "Period": "1s", + "Limit": 5 + }, + "QoSOptions": { + "ExceptionsAllowedBeforeBreaking": 3, + "DurationOfBreak": 5000, + "TimeoutValue": 25000 + } + }, + { + "UpstreamHttpMethod": [ "DELETE" ], + "UpstreamPathTemplate": "/api/v1/clients/{id}/audiences/{audience}", + "DownstreamPathTemplate": "/api/v1/clients/{id}/audiences/{audience}", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 5286 + } + ], + "RateLimitOptions": { + "ClientIdHeader": "Client", + "EnableRateLimiting": true, + "Period": "1s", + "Limit": 5 + }, + "QoSOptions": { + "ExceptionsAllowedBeforeBreaking": 3, + "DurationOfBreak": 5000, + "TimeoutValue": 25000 + } } ] } From c64bbd7d34f6c62b78b04360f6a1096f6cf968ec Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 23:19:40 -0300 Subject: [PATCH 114/117] feature(#20): this commit introduces support for multiple audiences in federation options configuration --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2f18fd1..942716f 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ services.AddFederation(options => options.Realm = settings.Federation.Realm; // e.g., "acme-corp" options.ClientId = settings.Federation.ClientId; // e.g., "client-id-generated" options.ClientSecret = settings.Federation.ClientSecret; // e.g., "secret-key-generated" + options.Audiences = settings.Federation.Audiences // e.g., "[ "acme-corp-operations", "acme-corp-backoffice" ]" }); ``` From 49e5a1f7c99cd081b63772f42778b9000f78e2e8 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 23:22:23 -0300 Subject: [PATCH 115/117] fix: correct docker pull command URL in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 942716f..5020203 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ You can pull either: ```bash docker pull httpsrichardy/federation:latest -docker pull httprichardy/federation:3.0.0 +docker pull httpsrichardy/federation:3.0.0 ``` To run the container, provide the required environment variables for database and administration bootstrap: From 057515667958063c79821c66f18b30c434408a07 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 23:26:52 -0300 Subject: [PATCH 116/117] feature(#20): this commit updates README to reflect the new version 4.0.0 for docker pull command --- CHANGELOG | 6 ++++++ README.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index e0f9db6..038c5eb 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,9 @@ +# 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. + +this is a breaking change for users upgrading from 3.1.1 and earlier, because integrations that assumed a 1:1 mapping between realm and client must now be updated to work with explicit clients and their configured audiences. + # 3.1.1 - 2026-03-30 this release includes internal improvements and performance optimizations to enhance the overall system efficiency. We removed unused experimental scope-related code that is no longer required and introduced new database indexes to improve query performance. These changes help ensure faster and more reliable data access while maintaining existing functionality without breaking changes. diff --git a/README.md b/README.md index 5020203..4076d97 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ You can pull either: ```bash docker pull httpsrichardy/federation:latest -docker pull httpsrichardy/federation:3.0.0 +docker pull httpsrichardy/federation:4.0.0 ``` To run the container, provide the required environment variables for database and administration bootstrap: From 96cd3b418eca1c92642fb440ba9eaed05dc78721 Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Mon, 20 Apr 2026 23:29:28 -0300 Subject: [PATCH 117/117] feature(#20)!: this commit updates commit message patterns for version bumping in git-version.yml --- GitVersion.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/GitVersion.yml b/GitVersion.yml index 0608264..0065c69 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -8,6 +8,6 @@ branches: commit-message-incrementing: Enabled -major-version-bump-message: '^feat\\(?#\\d+\\)?!|BREAKING CHANGE|!' -minor-version-bump-message: '^feat(\\(?#\\d+\\)?)?:' -patch-version-bump-message: '^fix(\\(?#\\d+\\)?)?:' +major-version-bump-message: '^(feat|feature)(\(#\d+\))?!:|BREAKING CHANGE' +minor-version-bump-message: '^(feat|feature)(\(#\d+\))?:' +patch-version-bump-message: '^fix(\(#\d+\))?:'