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) 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..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,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() + var filters = ClientFilters.WithSpecifications() .WithClientId(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); 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/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)); + } +} 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..3851c27 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientCreationHandler.cs @@ -0,0 +1,29 @@ +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 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); + + var client = parameters.AsClient(credentials, realm); + + await clientCollection.InsertAsync(client, cancellation: cancellation); + + return Result.Success(client.AsResponse()); + } +} 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/Handlers/Client/ClientUpdateHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientUpdateHandler.cs new file mode 100644 index 0000000..81a9a9e --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Client/ClientUpdateHandler.cs @@ -0,0 +1,39 @@ +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); + } + + 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); + + return Result.Success(ClientMapper.AsResponse(updatedClient)); + } +} 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); + } +} 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/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/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/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") 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..b6839f9 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/ClientMapper.cs @@ -0,0 +1,33 @@ +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))] + }; + + 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, + Name = client.Name, + ClientId = client.ClientId, + ClientSecret = client.Secret, + Flows = client.Flows, + RedirectUris = client.RedirectUris + }; +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/RealmMapper.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/RealmMapper.cs index 00021f9..f8492d7 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,15 +22,12 @@ 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() { 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/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.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/Payloads/Client/ClientCreationScheme.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Client/ClientCreationScheme.cs new file mode 100644 index 0000000..0c39f29 --- /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; } = []; +} 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!; +} 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; } = []; +} 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/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; } +} 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; } +} 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!; +} 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!; +} 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.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()) : 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.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.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."); + } +} 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."); + } +} 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 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."); + }); + } +} 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 ]; } 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..7631f41 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Client.cs @@ -0,0 +1,18 @@ +namespace HttpsRichardy.Federation.Domain.Aggregates; + +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 Audiences { get; set; } = []; + 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); +} 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; } = []; } 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 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 new file mode 100644 index 0000000..48fed34 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Errors/ClientErrors.cs @@ -0,0 +1,34 @@ +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." + ); + + public static readonly Error ClientAlreadyHasAudience = new( + Code: "#ERROR-F4E2A", + 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." + ); + + public static readonly Error ClientDoesNotExist = new( + Code: "#ERROR-2D943", + Description: "The client with the specified ID does not exist." + ); +} 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..75dd8f6 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/ClientFiltersBuilder.cs @@ -0,0 +1,20 @@ +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; + } + + public ClientFiltersBuilder WithClientId(string? clientId) + { + if (!string.IsNullOrWhiteSpace(clientId)) + _filters.ClientId = clientId.Trim().Normalize(NormalizationForm.FormC); + + return this; + } +} 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/ClientFilters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/ClientFilters.cs new file mode 100644 index 0000000..abc79ba --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/ClientFilters.cs @@ -0,0 +1,11 @@ +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(); +} 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(); } 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 ); 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; 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(); } } 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..8f4cf30 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,16 @@ 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)), + new CreateIndexModel(Builders.IndexKeys.Ascending(client => client.ClientId)), + new CreateIndexModel(Builders.IndexKeys + .Ascending(client => client.RealmId) + .Ascending(client => client.ClientId)) }; userCollection.Indexes.CreateMany(userIndexes); @@ -61,5 +70,6 @@ public static void EnsureIndexes(this IMongoDatabase database) groupCollection.Indexes.CreateMany(groupIndexes); tokenCollection.Indexes.CreateMany(tokenIndexes); realmCollection.Indexes.CreateMany(realmIndexes); + clientCollection.Indexes.CreateMany(clientIndexes); } } 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..0afd6cf 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,9 @@ public static void AddValidators(this IServiceCollection services) services.AddTransient, RealmCreationValidator>(); services.AddTransient, RealmUpdateValidator>(); + services.AddTransient, ClientCreationSchemeValidator>(); + services.AddTransient, AssignClientPermissionValidator>(); + services.AddTransient, AssignUserPermissionValidator>(); services.AddTransient, AssignRealmPermissionValidator>(); } 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; 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"; } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs index a27484e..b8d7dbf 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs @@ -30,11 +30,19 @@ 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"; } + 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"; + } + public static class SecurityToken { public const string Value = nameof(Domain.Aggregates.SecurityToken.Value); 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 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..9c8a5d3 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/ClientFiltersStage.cs @@ -0,0 +1,21 @@ +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.Id, filters.Id), + 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)); + } +} + 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) }; diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/ClaimsBuilder.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/ClaimsBuilder.cs index 356c9db..e6550bf 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)); @@ -45,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/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 +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs index 1cf6472..22ee8e1 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs @@ -65,14 +65,17 @@ 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 realm = realmProvider.GetCurrentRealm(); + var claims = new ClaimsBuilder() - .WithRealmId(realm.Id.ToString()) + .WithSubject(client.Id) .WithRealmName(realm.Name) - .WithClientId(realm.ClientId) - .WithPermissions(realm.Permissions) + .WithClientId(client.Id) + .WithPermissions(client.Permissions) + .WithAudiences(client.Audiences.Select(audience => audience.Value)) .Build(); var privateKey = await GetPrivateKeyAsync(cancellation); @@ -82,7 +85,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 +223,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 +} 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..906dc9e --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/ClientsController.cs @@ -0,0 +1,185 @@ +namespace HttpsRichardy.Federation.WebApi.Controllers; + +[ApiController] +[ApiConventionType(typeof(ClientsConventions))] +[RealmRequired] +[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)] + public async Task CreateClientAsync([FromBody] ClientCreationScheme request, CancellationToken cancellation) + { + var result = await dispatcher.DispatchAsync(request, cancellation); + + return result switch + { + { 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), + }; + } + + [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), + + { IsFailure: true } when result.Error == ClientErrors.ClientAlreadyExists => + StatusCode(StatusCodes.Status409Conflict, result.Error), + }; + } + + [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)] + 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), + }; + } + + [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), + }; + } + + [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) + }; + } + + [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) + }; + } + + [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 new file mode 100644 index 0000000..c6bf3f9 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/ClientsConventions.cs @@ -0,0 +1,56 @@ +#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(typeof(ClientScheme), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(Error), StatusCodes.Status409Conflict)] + public static void CreateClientAsync(ClientCreationScheme request, CancellationToken cancellation) { } + + [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)] + [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)] + 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) { } + + [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) { } + + [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) { } + + [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) { } +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/BootstrapperExtension.cs index b2d2ab4..b5b7109 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("admin", cancellation: default); + + var defaultRealm = new Realm { Name = "master" }; + var defaultClient = new Client { Name = "admin", Flows = [Grant.ClientCredentials] }; - var defaultRealm = new Realm { Name = "master", ClientId = realmCredentials.ClientId }; var realmFilters = RealmFilters.WithSpecifications() .WithName("master") .Build(); @@ -31,7 +34,11 @@ public static async Task UseBootstrapperAsync(this IApplicationBuilder builder) return; } - defaultRealm.SecretHash = await passwordHasher.HashPasswordAsync(realmCredentials.ClientId + defaultRealm.Name); + 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(), @@ -39,10 +46,17 @@ public static async Task UseBootstrapperAsync(this IApplicationBuilder builder) RealmId = defaultRealm.Id })]; + 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); var userFilters = UserFilters.WithSpecifications() .WithUsername(settings.Administration.Username) 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(); } 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 diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs index a307eac..b82f8a5 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; @@ -31,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; diff --git a/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs new file mode 100644 index 0000000..533202f --- /dev/null +++ b/Applications/Backend/Tests/Integration/Endpoints/ClientEndpointTests.cs @@ -0,0 +1,1191 @@ +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(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 */ + 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"); + } + + [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); + } + + [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); + } + + [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); + } + + [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); + } + + [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); + } + + [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 = "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); + + /* act: send DELETE request to revoke first audience */ + var response = await httpClient.DeleteAsync($"api/v1/clients/{client.Id}/audiences/{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); + } +} diff --git a/Applications/Backend/Tests/Integration/Endpoints/ConnectEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/ConnectEndpointTests.cs index d479b3d..e9e1e9f 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, "nexus") + .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 } }; diff --git a/Applications/Backend/Tests/Integration/Endpoints/PermissionEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/PermissionEndpointTests.cs index e923db6..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,42 +142,22 @@ public async Task WhenPostPermissionsWithReservedSystemNameInNonMasterRealm_Shou Assert.NotNull(realm); Assert.Equal(HttpStatusCode.Created, realmResponse.StatusCode); - /* arrange: authenticate realm via OAuth 2.0 client_credentials */ - var oauthCredentials = new Dictionary - { - { "grant_type", "client_credentials" }, - { "client_id", realm.ClientId }, - { "client_secret", realm.ClientSecret } - }; - - 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); + /* arrange: use an authenticated identity in the target realm */ + var realmClient = factory.HttpClient.WithRealmHeader(realm.Name) + .WithAuthorization(masterAuthenticationResult.AccessToken); /* act: attempt to create a permission using a reserved system name */ - var payload = _fixture.Build() - .With(permission => permission.Name, Permissions.ViewRealms) - .Create(); + var payload = new PermissionCreationScheme + { + Name = Permissions.ViewRealms + }; 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); } diff --git a/Applications/Backend/Tests/Integration/Persistence/ClientPersistenceTests.cs b/Applications/Backend/Tests/Integration/Persistence/ClientPersistenceTests.cs new file mode 100644 index 0000000..098c30f --- /dev/null +++ b/Applications/Backend/Tests/Integration/Persistence/ClientPersistenceTests.cs @@ -0,0 +1,395 @@ +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 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() + .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); + } + + [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(); + } +} 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() { 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; 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 + } } ] } 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/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+\))?:' 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 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; 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 +} 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; diff --git a/README.md b/README.md index 2f18fd1..4076d97 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" ]" }); ``` @@ -64,7 +65,7 @@ You can pull either: ```bash docker pull httpsrichardy/federation:latest -docker pull httprichardy/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: