diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Connect/FetchJsonWebKeysHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Connect/FetchJsonWebKeysHandler.cs index 2c19cf5..87b4b5b 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Connect/FetchJsonWebKeysHandler.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Connect/FetchJsonWebKeysHandler.cs @@ -1,13 +1,30 @@ namespace HttpsRichardy.Federation.Application.Handlers.Connect; -public sealed class FetchJsonWebKeysHandler(ISecretCollection collection) : +public sealed class FetchJsonWebKeysHandler(ISecretCollection collection, IRealmCollection realmCollection) : IDispatchHandler> { public async Task> HandleAsync( FetchJsonWebKeysParameters parameters, CancellationToken cancellation = default) { - var secret = await collection.GetSecretAsync(cancellation: cancellation); - var jwks = JsonWebKeysMapper.AsJsonWebKeySetScheme(secret); + var realmFilters = RealmFilters.WithSpecifications() + .WithName(parameters.Realm) + .Build(); + + var realms = await realmCollection.GetRealmsAsync(realmFilters, cancellation); + var realm = realms.FirstOrDefault(); + + if (realm is null) + { + return Result.Failure(RealmErrors.RealmDoesNotExist); + } + + var filters = SecretFilters.WithSpecifications() + .WithCanValidate() + .WithRealm(realm.Id) + .Build(); + + var secrets = await collection.GetSecretsAsync(filters, cancellation); + var jwks = JsonWebKeysMapper.AsJsonWebKeySetScheme(secrets); return Result.Success(jwks); } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Connect/FetchOpenIDConfigurationsHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Connect/FetchOpenIDConfigurationsHandler.cs index c0de081..f72a71d 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Connect/FetchOpenIDConfigurationsHandler.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Connect/FetchOpenIDConfigurationsHandler.cs @@ -1,13 +1,25 @@ namespace HttpsRichardy.Federation.Application.Handlers.Connect; -public sealed class FetchOpenIDConfigurationHandler(IHostInformationProvider host) : +public sealed class FetchOpenIDConfigurationHandler(IHostInformationProvider host, IRealmCollection realmCollection) : IDispatchHandler> { - public Task> HandleAsync( + public async Task> HandleAsync( FetchOpenIDConfigurationParameters parameters, CancellationToken cancellation = default) { - var configuration = ConnectMapper.AsConfiguration(host.Address); + var realmFilters = RealmFilters.WithSpecifications() + .WithName(parameters.Realm) + .Build(); - return Task.FromResult(Result.Success(configuration)); + var realms = await realmCollection.GetRealmsAsync(realmFilters, cancellation); + var realm = realms.FirstOrDefault(); + + if (realm is null) + { + return Result.Failure(RealmErrors.RealmDoesNotExist); + } + + var configuration = ConnectMapper.AsConfiguration(host.Address, parameters.Realm); + + return Result.Success(configuration); } } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Realm/RealmCreationHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Realm/RealmCreationHandler.cs index 63d9641..f8d413d 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Realm/RealmCreationHandler.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Realm/RealmCreationHandler.cs @@ -1,6 +1,9 @@ namespace HttpsRichardy.Federation.Application.Handlers.Realm; -public sealed class RealmCreationHandler(IRealmCollection collection, IClientCredentialsGenerator credentialsGenerator) : +public sealed class RealmCreationHandler( + IRealmCollection collection, + IClientCredentialsGenerator credentialsGenerator, + ISecretRotationService secretRotationService) : IDispatchHandler> { public async Task> HandleAsync( @@ -31,6 +34,7 @@ public async Task> HandleAsync( .ToList(); await collection.InsertAsync(realm, cancellation: cancellation); + await secretRotationService.EnsureSecretExistsAsync(realm, cancellation); return Result.Success(RealmMapper.AsResponse(realm)); } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/FetchRealmSecretsHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/FetchRealmSecretsHandler.cs new file mode 100644 index 0000000..a7de5f6 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/FetchRealmSecretsHandler.cs @@ -0,0 +1,34 @@ +namespace HttpsRichardy.Federation.Application.Handlers.Secret; + +public sealed class FetchRealmSecretsHandler(ISecretCollection collection, IRealmCollection realmCollection) : + IDispatchHandler>> +{ + public async Task>> HandleAsync( + FetchRealmSecretsParameters parameters, CancellationToken cancellation = default) + { + var realmFilters = RealmFilters.WithSpecifications() + .WithIdentifier(parameters.RealmId) + .Build(); + + var realms = await realmCollection.GetRealmsAsync(realmFilters, cancellation); + var realm = realms.FirstOrDefault(); + + if (realm is null) + { + return Result>.Failure(RealmErrors.RealmDoesNotExist); + } + + var filters = SecretFilters.WithSpecifications() + .WithRealm(parameters.RealmId) + .Build(); + + var secrets = await collection.GetSecretsAsync(filters, cancellation); + var schemes = secrets + .OrderByDescending(secret => secret.CreatedAt) + .Select(secret => secret.AsResponse()) + .ToList() + .AsReadOnly(); + + return Result>.Success(schemes); + } +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/RotateRealmSecretsHandler.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/RotateRealmSecretsHandler.cs new file mode 100644 index 0000000..afa07d6 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Secret/RotateRealmSecretsHandler.cs @@ -0,0 +1,25 @@ +namespace HttpsRichardy.Federation.Application.Handlers.Secret; + +public sealed class RotateRealmSecretsHandler(ISecretRotationService rotationService, IRealmCollection realmCollection) : + IDispatchHandler +{ + public async Task HandleAsync( + RotateRealmSecretsParameters parameters, CancellationToken cancellation = default) + { + var realmFilters = RealmFilters.WithSpecifications() + .WithIdentifier(parameters.RealmId) + .Build(); + + var realms = await realmCollection.GetRealmsAsync(realmFilters, cancellation); + var realm = realms.FirstOrDefault(); + + if (realm is null) + { + return Result.Failure(RealmErrors.RealmDoesNotExist); + } + + await rotationService.RotateSecretAsync(realm, cancellation); + + return Result.Success(); + } +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/JsonWebKeysMapper.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/JsonWebKeysMapper.cs index c83fd98..c1e87b2 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/JsonWebKeysMapper.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/JsonWebKeysMapper.cs @@ -15,11 +15,11 @@ public static JsonWebKeyScheme AsJsonWebKeys(Secret secret) }; } - public static JsonWebKeySetScheme AsJsonWebKeySetScheme(Secret secret) + public static JsonWebKeySetScheme AsJsonWebKeySetScheme(IReadOnlyCollection secrets) { return new JsonWebKeySetScheme { - Keys = [JsonWebKeysMapper.AsJsonWebKeys(secret)] + Keys = [.. secrets.Select(secret => JsonWebKeysMapper.AsJsonWebKeys(secret))] }; } } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/OpenIDMapper.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/OpenIDMapper.cs index f97aacb..06d2f1c 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/OpenIDMapper.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/OpenIDMapper.cs @@ -2,13 +2,13 @@ namespace HttpsRichardy.Federation.Application.Mappers; public static class ConnectMapper { - public static OpenIDConfigurationScheme AsConfiguration(Uri baseUri) + public static OpenIDConfigurationScheme AsConfiguration(Uri baseUri, string realm) { var issuer = baseUri.GetLeftPart(UriPartial.Authority); var authorizeUri = new Uri(baseUri, OpenIDEndpoints.Authorize); var tokenUri = new Uri(baseUri, OpenIDEndpoints.Token); var userInfoUri = new Uri(baseUri, OpenIDEndpoints.UserInfo); - var jwksUri = new Uri(baseUri, OpenIDEndpoints.Jwks); + var jwksUri = new Uri(baseUri, $"{realm}/{OpenIDEndpoints.Jwks}"); var configuration = new OpenIDConfigurationScheme { diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/SecretMapper.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/SecretMapper.cs new file mode 100644 index 0000000..c1fe781 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/SecretMapper.cs @@ -0,0 +1,12 @@ +namespace HttpsRichardy.Federation.Application.Mappers; + +public static class SecretMapper +{ + public static SecretScheme AsResponse(this Secret secret) => new() + { + Id = secret.Id, + CreatedAt = secret.CreatedAt, + ExpiresAt = secret.ExpiresAt, + GracePeriodEndsAt = secret.GracePeriodEndsAt + }; +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Connect/FetchJsonWebKeysParameters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Connect/FetchJsonWebKeysParameters.cs index e69b217..b8634e3 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Connect/FetchJsonWebKeysParameters.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Connect/FetchJsonWebKeysParameters.cs @@ -1,3 +1,6 @@ namespace HttpsRichardy.Federation.Application.Payloads.Connect; -public sealed record FetchJsonWebKeysParameters : IDispatchable>; +public sealed record FetchJsonWebKeysParameters : IDispatchable> +{ + public string Realm { get; init; } = default!; +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Connect/FetchOpenIDConfigurationParameters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Connect/FetchOpenIDConfigurationParameters.cs index 2c0283b..2b42817 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Connect/FetchOpenIDConfigurationParameters.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Connect/FetchOpenIDConfigurationParameters.cs @@ -1,4 +1,7 @@ namespace HttpsRichardy.Federation.Application.Payloads.Connect; public sealed record FetchOpenIDConfigurationParameters : - IDispatchable>; \ No newline at end of file + IDispatchable> +{ + public string Realm { get; init; } = default!; +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/FetchRealmSecretsParameters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/FetchRealmSecretsParameters.cs new file mode 100644 index 0000000..080d705 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/FetchRealmSecretsParameters.cs @@ -0,0 +1,7 @@ +namespace HttpsRichardy.Federation.Application.Payloads.Secret; + +public sealed record FetchRealmSecretsParameters : + IDispatchable>> +{ + public string RealmId { get; init; } = default!; +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/RotateRealmSecretsParameters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/RotateRealmSecretsParameters.cs new file mode 100644 index 0000000..7f7c577 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/RotateRealmSecretsParameters.cs @@ -0,0 +1,7 @@ +namespace HttpsRichardy.Federation.Application.Payloads.Secret; + +public sealed record RotateRealmSecretsParameters : + IDispatchable +{ + public string RealmId { get; init; } = default!; +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/SecretScheme.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/SecretScheme.cs new file mode 100644 index 0000000..6d5d222 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Payloads/Secret/SecretScheme.cs @@ -0,0 +1,9 @@ +namespace HttpsRichardy.Federation.Application.Payloads.Secret; + +public sealed record SecretScheme +{ + public string Id { get; set; } = default!; + public DateTime CreatedAt { get; set; } + public DateTime? ExpiresAt { get; set; } + public DateTime? GracePeriodEndsAt { get; set; } +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecretRotationService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecretRotationService.cs new file mode 100644 index 0000000..167c2b7 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Services/ISecretRotationService.cs @@ -0,0 +1,30 @@ +namespace HttpsRichardy.Federation.Application.Services; + +public interface ISecretRotationService +{ + public Task EnsureSecretExistsAsync( + Realm realm, + CancellationToken cancellation = default + ); + + public Task PruneSecretsAsync( + Realm realm, + CancellationToken cancellation = default + ); + + public Task CreateSecretAsync( + Realm realm, + CancellationToken cancellation = default + ); + + public Task RotateSecretAsync( + Realm realm, + CancellationToken cancellation = default + ); + + public Task DeleteSecretAsync( + Realm realm, + Secret secret, + CancellationToken cancellation = default + ); +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Usings.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Usings.cs index e3eb8ba..c8d328d 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Application/Usings.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Application/Usings.cs @@ -20,6 +20,7 @@ global using HttpsRichardy.Federation.Application.Payloads.Group; global using HttpsRichardy.Federation.Application.Payloads.Permission; global using HttpsRichardy.Federation.Application.Payloads.Realm; +global using HttpsRichardy.Federation.Application.Payloads.Secret; global using HttpsRichardy.Federation.Application.Payloads.User; global using HttpsRichardy.Federation.Application.Payloads.Client; global using HttpsRichardy.Federation.Application.Payloads.Connect; diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Secret.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Secret.cs index 86dd1e8..a0ab26b 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Secret.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Aggregates/Secret.cs @@ -4,4 +4,8 @@ public sealed class Secret : Aggregate { public string PrivateKey { get; set; } = default!; public string PublicKey { get; set; } = default!; -} \ No newline at end of file + public string RealmId { get; set; } = default!; + + public DateTime? ExpiresAt { get; set; } + public DateTime? GracePeriodEndsAt { get; set; } +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Collections/ISecretCollection.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Collections/ISecretCollection.cs index e5f9848..87e7009 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Collections/ISecretCollection.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Collections/ISecretCollection.cs @@ -2,5 +2,13 @@ namespace HttpsRichardy.Federation.Domain.Collections; public interface ISecretCollection : IAggregateCollection { - public Task GetSecretAsync(CancellationToken cancellation = default); + public Task> GetSecretsAsync( + SecretFilters filters, + CancellationToken cancellation = default + ); + + public Task CountSecretsAsync( + SecretFilters filters, + CancellationToken cancellation = default + ); } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs new file mode 100644 index 0000000..d5abd92 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/SecretFiltersBuilder.cs @@ -0,0 +1,45 @@ +namespace HttpsRichardy.Federation.Domain.Filtering.Builders; + +public sealed class SecretFiltersBuilder : + FiltersBuilderBase +{ + public SecretFiltersBuilder WithRealm(string? realmId) + { + if (!string.IsNullOrWhiteSpace(realmId)) + _filters.RealmId = realmId; + + return this; + } + + public SecretFiltersBuilder WithCanSign(DateTime? now = null) + { + _filters.CanSign = true; + _filters.Now = now; + + return this; + } + + public SecretFiltersBuilder WithCanValidate(DateTime? now = null) + { + _filters.CanValidate = true; + _filters.Now = now; + + return this; + } + + public SecretFiltersBuilder WithInGrace(DateTime? now = null) + { + _filters.InGracePeriod = true; + _filters.Now = now; + + return this; + } + + public SecretFiltersBuilder WithExpired(DateTime? now = null) + { + _filters.IsExpired = true; + _filters.Now = now; + + return this; + } +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/RealmFilters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/RealmFilters.cs index e8acd5e..43afeb1 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/RealmFilters.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/RealmFilters.cs @@ -4,5 +4,6 @@ public sealed class RealmFilters : Filters { public string? Name { get; set; } + public static RealmFilters WithoutFilters => new(); public static RealmFiltersBuilder WithSpecifications() => new(); } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/SecretFilters.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/SecretFilters.cs new file mode 100644 index 0000000..719039a --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Domain/Filtering/SecretFilters.cs @@ -0,0 +1,14 @@ +namespace HttpsRichardy.Federation.Domain.Filtering; + +public sealed class SecretFilters : Filters +{ + public string? RealmId { get; set; } + public bool? CanSign { get; set; } + public bool? CanValidate { get; set; } + public bool? InGracePeriod { get; set; } + public bool? IsExpired { get; set; } + public DateTime? Now { get; set; } + + public static SecretFilters WithoutFilters => new(); + public static SecretFiltersBuilder WithSpecifications() => new(); +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ApplicationServicesExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ApplicationServicesExtension.cs index 108da15..2dbc21b 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ApplicationServicesExtension.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ApplicationServicesExtension.cs @@ -8,6 +8,7 @@ public static void AddServices(this IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/SecretsExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/SecretsExtension.cs deleted file mode 100644 index bdde86a..0000000 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/SecretsExtension.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace HttpsRichardy.Federation.Infrastructure.IoC.Extensions; - -public static class SecretsExtension -{ - public static void AddInitialSecrets(this IServiceCollection services) - { - var serviceProvider = services.BuildServiceProvider(); - var secretRepository = serviceProvider.GetRequiredService(); - - var secret = secretRepository.GetSecretAsync() - .GetAwaiter() - .GetResult(); - - /* if no secret exists, generate an initial one to sign JWT tokens */ - if (secret is null) - { - using var rsa = RSA.Create(2048); - - secret = new Secret - { - PrivateKey = Convert.ToBase64String(rsa.ExportRSAPrivateKey()), - PublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey()) - }; - - secretRepository.InsertAsync(secret) - .GetAwaiter() - .GetResult(); - } - } -} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ServicesExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ServicesExtension.cs index 5493cc7..a692365 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ServicesExtension.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure.IoC/Extensions/ServicesExtension.cs @@ -11,6 +11,5 @@ public static void AddInfrastructure(this IServiceCollection services, IConfigur services.AddServices(); services.AddMediator(); services.AddValidators(); - services.AddInitialSecrets(); } } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs index b8d7dbf..4caa4af 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Constants/Documents.cs @@ -43,6 +43,14 @@ public static class Client public const string Id = "_id"; } + public static class Secret + { + public const string RealmId = nameof(Domain.Aggregates.Secret.RealmId); + public const string ExpiresAt = nameof(Domain.Aggregates.Secret.ExpiresAt); + public const string GracePeriodEndsAt = nameof(Domain.Aggregates.Secret.GracePeriodEndsAt); + public const string Id = "_id"; + } + public static class SecurityToken { public const string Value = nameof(Domain.Aggregates.SecurityToken.Value); diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Persistence/SecretCollection.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Persistence/SecretCollection.cs index a314b92..a13fa4f 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Persistence/SecretCollection.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Persistence/SecretCollection.cs @@ -1,13 +1,42 @@ namespace HttpsRichardy.Federation.Infrastructure.Persistence; -public sealed class SecretCollection(IMongoDatabase database) : +public sealed class SecretCollection(IMongoDatabase database, IRealmProvider realmProvider) : AggregateCollection(database, Collections.Secrets), ISecretCollection { - public async Task GetSecretAsync(CancellationToken cancellation = default) + public async Task> GetSecretsAsync( + SecretFilters filters, CancellationToken cancellation = default) { - return await _collection - .Find(Builders.Filter.Empty) - .FirstOrDefaultAsync(cancellation); + var pipeline = PipelineDefinitionBuilder + .For() + .As() + .FilterSecrets(filters, realmProvider) + .Paginate(filters.Pagination) + .Sort(filters.Sort); + + var options = new AggregateOptions { AllowDiskUse = true }; + var aggregation = await _collection.AggregateAsync(pipeline, options, cancellation); + + var bsonDocuments = await aggregation.ToListAsync(cancellation); + var secrets = bsonDocuments + .Select(bson => BsonSerializer.Deserialize(bson)) + .ToList(); + + return secrets; + } + + public async Task CountSecretsAsync( + SecretFilters filters, CancellationToken cancellation = default) + { + var pipeline = PipelineDefinitionBuilder + .For() + .As() + .FilterSecrets(filters, realmProvider) + .Count(); + + var aggregation = await _collection.AggregateAsync(pipeline, cancellationToken: cancellation); + var result = await aggregation.FirstOrDefaultAsync(cancellation); + + return result?.Count ?? 0; } -} \ No newline at end of file +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/SecretFiltersStage.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/SecretFiltersStage.cs new file mode 100644 index 0000000..2852591 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/SecretFiltersStage.cs @@ -0,0 +1,55 @@ +namespace HttpsRichardy.Federation.Infrastructure.Pipelines; + +public static class SecretFiltersStage +{ + public static PipelineDefinition FilterSecrets(this PipelineDefinition pipeline, + SecretFilters filters, IRealmProvider realmProvider) + { + var realm = realmProvider.GetCurrentRealm(); + var now = filters.Now ?? DateTime.UtcNow; + + var definitions = new List> + { + FilterDefinitions.MatchIfNotEmpty(Documents.Secret.Id, filters.Id), + FilterDefinitions.MatchIfNotEmpty(Documents.Secret.RealmId, filters.RealmId ?? realm?.Id), + }; + + if (filters.CanSign is true) + { + definitions.Add(Builders.Filter.Or( + Builders.Filter.Eq(Documents.Secret.ExpiresAt, BsonNull.Value), + Builders.Filter.Gt(Documents.Secret.ExpiresAt, now) + )); + } + + if (filters.CanValidate is true) + { + var canSign = Builders.Filter.Or( + Builders.Filter.Eq(Documents.Secret.ExpiresAt, BsonNull.Value), + Builders.Filter.Gt(Documents.Secret.ExpiresAt, now) + ); + + var inGrace = Builders.Filter.And( + Builders.Filter.Ne(Documents.Secret.ExpiresAt, BsonNull.Value), + Builders.Filter.Lte(Documents.Secret.ExpiresAt, now), + Builders.Filter.Ne(Documents.Secret.GracePeriodEndsAt, BsonNull.Value), + Builders.Filter.Gt(Documents.Secret.GracePeriodEndsAt, now) + ); + + definitions.Add(Builders.Filter.Or(canSign, inGrace)); + } + + if (filters.InGracePeriod is true) + { + definitions.Add(Builders.Filter.And( + Builders.Filter.Ne(Documents.Secret.ExpiresAt, BsonNull.Value), + Builders.Filter.Lte(Documents.Secret.ExpiresAt, now), + + Builders.Filter.Ne(Documents.Secret.GracePeriodEndsAt, BsonNull.Value), + Builders.Filter.Gt(Documents.Secret.GracePeriodEndsAt, now) + )); + } + + return pipeline.Match(Builders.Filter.And(definitions)); + } +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs index 22ee8e1..0e7450e 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/JwtSecurityTokenService.cs @@ -5,6 +5,7 @@ public sealed class JwtSecurityTokenService( ITokenCollection tokenCollection, IRealmProvider realmProvider, IGroupCollection groupCollection, + ISecretRotationService secretRotationService, IHostInformationProvider host ) : ISecurityTokenService { @@ -148,14 +149,14 @@ public async Task> GenerateRefreshTokenAsync(User user, Ca public async Task ValidateTokenAsync(SecurityToken token) { var tokenHandler = new JwtSecurityTokenHandler(); - var publicKey = await GetPublicKeyAsync(); + var publicKeys = await GetPublicKeyAsync(); var validationParameters = new TokenValidationParameters { ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = true, - IssuerSigningKey = publicKey, + IssuerSigningKeys = publicKeys, ValidateIssuerSigningKey = true, ClockSkew = TimeSpan.FromSeconds(30) }; @@ -214,13 +215,51 @@ public Task ValidateRefreshTokenAsync(SecurityToken token, CancellationT private async Task GetPrivateKeyAsync(CancellationToken cancellation = default) { - var secret = await secretCollection.GetSecretAsync(cancellation); - return Common.Utilities.RsaHelper.CreateSecurityKeyFromPrivateKey(secret.PrivateKey); + var realm = realmProvider.GetCurrentRealm(); + var filters = SecretFilters.WithSpecifications() + .WithRealm(realm.Id) + .WithCanSign() + .Build(); + + var secrets = await secretCollection.GetSecretsAsync(filters, cancellation); + var secret = secrets + .OrderByDescending(secret => secret.CreatedAt) + .FirstOrDefault(); + + if (secret is null) + { + await secretRotationService.EnsureSecretExistsAsync(realm, cancellation); + + secrets = await secretCollection.GetSecretsAsync(filters, cancellation); + secret = secrets + .OrderByDescending(secret => secret.CreatedAt) + .FirstOrDefault() ?? throw new InvalidOperationException($"no signing key available for realm '{realm.Id}'."); + } + + var key = Common.Utilities.RsaHelper.CreateSecurityKeyFromPrivateKey(secret.PrivateKey); + + key.KeyId = secret.Id; + + return key; } - private async Task GetPublicKeyAsync(CancellationToken cancellation = default) + private async Task> GetPublicKeyAsync(CancellationToken cancellation = default) { - var secret = await secretCollection.GetSecretAsync(cancellation); - return Common.Utilities.RsaHelper.CreateSecurityKeyFromPublicKey(secret.PublicKey); + var realm = realmProvider.GetCurrentRealm(); + var filters = SecretFilters.WithSpecifications() + .WithRealm(realm.Id) + .WithCanValidate() + .Build(); + + var secrets = await secretCollection.GetSecretsAsync(filters, cancellation); + + return [.. secrets.Select(secret => + { + var key = Common.Utilities.RsaHelper.CreateSecurityKeyFromPublicKey(secret.PublicKey); + + key.KeyId = secret.Id; + + return key; + })]; } } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs new file mode 100644 index 0000000..e04a5d3 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.Infrastructure/Security/SecretRotationService.cs @@ -0,0 +1,98 @@ +using HttpsRichardy.Internal.Essentials.Contracts.Behaviors; + +using Realm = HttpsRichardy.Federation.Domain.Aggregates.Realm; +using Secret = HttpsRichardy.Federation.Domain.Aggregates.Secret; + +namespace HttpsRichardy.Federation.Infrastructure.Security; + +public sealed class SecretRotationService(ISecretCollection secretCollection) : ISecretRotationService +{ + private static readonly TimeSpan _keyLifetime = TimeSpan.FromDays(30); + private static readonly TimeSpan _gracePeriod = TimeSpan.FromDays(1); + + public async Task CreateSecretAsync(Realm realm, CancellationToken cancellation = default) + { + using var rsa = RSA.Create(2048); + + var now = DateTime.UtcNow; + var secret = new Secret + { + RealmId = realm.Id, + + PrivateKey = Convert.ToBase64String(rsa.ExportRSAPrivateKey()), + PublicKey = Convert.ToBase64String(rsa.ExportRSAPublicKey()), + + CreatedAt = now, + ExpiresAt = now.Add(_keyLifetime), + }; + + await secretCollection.InsertAsync(secret, cancellation: cancellation); + } + + public async Task DeleteSecretAsync(Realm realm, Secret secret, CancellationToken cancellation = default) + { + if (secret.GracePeriodEndsAt is not null && secret.GracePeriodEndsAt <= DateTime.UtcNow) + { + await secretCollection.DeleteAsync(secret, behavior: DeletionBehavior.Hard, cancellation: cancellation); + } + } + + public async Task EnsureSecretExistsAsync(Realm realm, CancellationToken cancellation = default) + { + var filters = SecretFilters.WithSpecifications() + .WithRealm(realm.Id) + .WithCanSign() + .Build(); + + var secrets = await secretCollection.GetSecretsAsync(filters, cancellation); + var current = secrets + .OrderByDescending(secret => secret.CreatedAt) + .FirstOrDefault(); + + if (current is null) + { + await CreateSecretAsync(realm, cancellation); + } + } + + public async Task PruneSecretsAsync(Realm realm, CancellationToken cancellation = default) + { + var filters = SecretFilters.WithSpecifications() + .WithRealm(realm.Id) + .Build(); + + var secrets = await secretCollection.GetSecretsAsync(filters, cancellation); + + foreach (var secret in secrets) + { + await DeleteSecretAsync(realm, secret, cancellation); + } + } + + public async Task RotateSecretAsync(Realm realm, CancellationToken cancellation = default) + { + var now = DateTime.UtcNow; + var filters = SecretFilters.WithSpecifications() + .WithRealm(realm.Id) + .WithCanSign(now) + .Build(); + + var secrets = await secretCollection.GetSecretsAsync(filters, cancellation); + var current = secrets + .OrderByDescending(secret => secret.CreatedAt) + .FirstOrDefault(); + + if (current is null) + { + await CreateSecretAsync(realm, cancellation); + return; + } + + await CreateSecretAsync(realm, cancellation); + + current.ExpiresAt = now; + current.GracePeriodEndsAt = now.Add(_gracePeriod); + + await secretCollection.UpdateAsync(current, cancellation: cancellation); + } +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/RealmsController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/RealmsController.cs index cd325e4..5ddf7a0 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/RealmsController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/RealmsController.cs @@ -72,6 +72,41 @@ public async Task DeleteRealmAsync([FromRoute] string id, [FromQu }; } + [HttpGet("{id}/secrets")] + [Authorize(Roles = Permissions.ViewRealms)] + [Stability(Stability.Stable)] + public async Task GetRealmSecretsAsync([FromRoute] string id, [FromQuery] FetchRealmSecretsParameters request, CancellationToken cancellation) + { + var result = await dispatcher.DispatchAsync(request with { RealmId = id }, cancellation); + + return result switch + { + { IsSuccess: true } when result.Data is not null => + StatusCode(StatusCodes.Status200OK, result.Data), + + { IsFailure: true } when result.Error == RealmErrors.RealmDoesNotExist => + StatusCode(StatusCodes.Status404NotFound, result.Error), + }; + } + + [HttpPost("{id}/secrets/rotate")] + [Authorize(Roles = Permissions.EditRealm)] + [Stability(Stability.Stable)] + public async Task RotateRealmSecretsAsync([FromRoute] string id, CancellationToken cancellation) + { + var request = new RotateRealmSecretsParameters { RealmId = id }; + var result = await dispatcher.DispatchAsync(request, cancellation); + + return result switch + { + { IsSuccess: true } => + StatusCode(StatusCodes.Status204NoContent), + + { IsFailure: true } when result.Error == RealmErrors.RealmDoesNotExist => + StatusCode(StatusCodes.Status404NotFound, result.Error), + }; + } + [HttpGet("{id}/permissions")] [Authorize(Roles = Permissions.ViewPermissions)] [Stability(Stability.Stable)] diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/WellKnownController.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/WellKnownController.cs index 0ac7404..be04919 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/WellKnownController.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Controllers/WellKnownController.cs @@ -2,20 +2,22 @@ namespace HttpsRichardy.Federation.WebApi.Controllers; [ApiController] [ApiConventionType(typeof(WellKnownConventions))] -[Route(".well-known")] +[Route("{realm}/.well-known")] public sealed class WellKnownController(IDispatcher dispatcher) : ControllerBase { [HttpGet("openid-configuration")] [Stability(Stability.Stable)] public async Task GetConfigurationAsync([FromQuery] FetchOpenIDConfigurationParameters request, CancellationToken cancellation) { - var result = await dispatcher.DispatchAsync(request, cancellation); + var result = await dispatcher.DispatchAsync(request with { Realm = (string)RouteData.Values["realm"]! }, cancellation); - // we know the switch here is not strictly necessary since we only handle the success case, - // but we keep it for consistency with the rest of the codebase and to follow established patterns. return result switch { - { IsSuccess: true } => StatusCode(StatusCodes.Status200OK, result.Data), + { IsSuccess: true } when result.Data is not null => + StatusCode(StatusCodes.Status200OK, result.Data), + + { IsFailure: true } when result.Error == RealmErrors.RealmDoesNotExist => + StatusCode(StatusCodes.Status404NotFound, result.Error) }; } @@ -23,13 +25,15 @@ public async Task GetConfigurationAsync([FromQuery] FetchOpenIDCo [Stability(Stability.Stable)] public async Task GetJsonWebKeysAsync([FromQuery] FetchJsonWebKeysParameters request, CancellationToken cancellation) { - var result = await dispatcher.DispatchAsync(request, cancellation); + var result = await dispatcher.DispatchAsync(request with { Realm = (string)RouteData.Values["realm"]! }, cancellation); - // we know the switch here is not strictly necessary since we only handle the success case, - // but we keep it for consistency with the rest of the codebase and to follow established patterns. return result switch { - { IsSuccess: true } => StatusCode(StatusCodes.Status200OK, result.Data), + { IsSuccess: true } when result.Data is not null => + StatusCode(StatusCodes.Status200OK, result.Data), + + { IsFailure: true } when result.Error == RealmErrors.RealmDoesNotExist => + StatusCode(StatusCodes.Status404NotFound, result.Error) }; } } diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/RealmsConventions.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/RealmsConventions.cs index 2fe098b..0fcadf0 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/RealmsConventions.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Conventions/RealmsConventions.cs @@ -23,6 +23,16 @@ public static void UpdateRealmAsync(string id, RealmUpdateScheme request, Cancel [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] public static void DeleteRealmAsync(string id, CancellationToken cancellation) { } + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + [ProducesResponseType(typeof(IReadOnlyCollection), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] + public static void GetRealmSecretsAsync(string id, FetchRealmSecretsParameters request, CancellationToken cancellation) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] + public static void RotateRealmSecretsAsync(string id, CancellationToken cancellation) { } + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] [ProducesResponseType(typeof(IReadOnlyCollection), StatusCodes.Status200OK)] [ProducesResponseType(typeof(Error), StatusCodes.Status404NotFound)] diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/AuthenticationExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/AuthenticationExtension.cs index 67c08ee..9bbf67c 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/AuthenticationExtension.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/AuthenticationExtension.cs @@ -6,21 +6,72 @@ public static class AuthenticationExtension public static IServiceCollection AddJwtAuthentication(this IServiceCollection services) { var serviceProvider = services.BuildServiceProvider(); - var secretRepository = serviceProvider.GetRequiredService(); + var accessor = serviceProvider.GetRequiredService(); - var secret = secretRepository.GetSecretAsync() - .GetAwaiter() - .GetResult(); - - var publicKey = Common.Utilities.RsaHelper.CreateSecurityKeyFromPublicKey(secret.PublicKey); var validationParameters = new TokenValidationParameters { ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = true, ValidateIssuerSigningKey = true, - IssuerSigningKey = publicKey, - ClockSkew = TimeSpan.Zero + ClockSkew = TimeSpan.FromSeconds(30), + IssuerSigningKeyResolver = (token, _, kid, _) => + { + var context = accessor.HttpContext; + if (context is null || string.IsNullOrWhiteSpace(token)) + return []; + + var secretCollection = context.RequestServices.GetRequiredService(); + var realmCollection = context.RequestServices.GetRequiredService(); + + var jsonWebToken = new Microsoft.IdentityModel.JsonWebTokens.JsonWebToken(token); + var realmId = jsonWebToken.Claims.FirstOrDefault(claim => claim.Type == Infrastructure.Constants.IdentityClaimNames.RealmId)?.Value; + + if (string.IsNullOrWhiteSpace(realmId)) + { + var realmName = jsonWebToken.Claims.FirstOrDefault(claim => claim.Type == Infrastructure.Constants.IdentityClaimNames.Realm)?.Value; + + if (string.IsNullOrWhiteSpace(realmName)) + return []; + + var realmFilters = RealmFilters.WithSpecifications() + .WithName(realmName) + .Build(); + + var realms = realmCollection + .GetRealmsAsync(realmFilters, context.RequestAborted) + .GetAwaiter() + .GetResult(); + + realmId = realms.FirstOrDefault()?.Id; + } + + if (string.IsNullOrWhiteSpace(realmId)) + return []; + + var secretFilters = SecretFilters.WithSpecifications() + .WithRealm(realmId) + .WithCanValidate() + .Build(); + + var secrets = secretCollection + .GetSecretsAsync(secretFilters, context.RequestAborted) + .GetAwaiter() + .GetResult(); + + if (!string.IsNullOrWhiteSpace(kid)) + { + secrets = [.. secrets.Where(secret => secret.Id == kid)]; + } + + return [.. secrets.Select(secret => + { + var key = Common.Utilities.RsaHelper.CreateSecurityKeyFromPublicKey(secret.PublicKey); + key.KeyId = secret.Id; + + return key; + })]; + } }; var builder = services.AddAuthentication(options => diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/WebInfrastructureExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/WebInfrastructureExtension.cs index 26b3ede..558550f 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/WebInfrastructureExtension.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/WebInfrastructureExtension.cs @@ -14,6 +14,7 @@ public static void AddWebComposition(this IServiceCollection services, IWebHostE services.AddProviders(); services.AddOpenApiSpecification(); services.AddRazorPages(); + services.AddWorkers(); services.AddFluentValidationAutoValidation(options => { options.DisableDataAnnotationsValidation = true; diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/WorkersExtension.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/WorkersExtension.cs new file mode 100644 index 0000000..08c32c8 --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Extensions/WorkersExtension.cs @@ -0,0 +1,10 @@ +namespace HttpsRichardy.Federation.WebApi.Extensions; + +[ExcludeFromCodeCoverage] +public static class WorkersExtension +{ + public static void AddWorkers(this IServiceCollection services) + { + services.AddHostedService(); + } +} diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs index b82f8a5..852de72 100644 --- a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Usings.cs @@ -30,6 +30,7 @@ global using HttpsRichardy.Federation.Application.Payloads.Authorization; global using HttpsRichardy.Federation.Application.Payloads.Permission; global using HttpsRichardy.Federation.Application.Payloads.Realm; +global using HttpsRichardy.Federation.Application.Payloads.Secret; global using HttpsRichardy.Federation.Application.Payloads.User; global using HttpsRichardy.Federation.Application.Payloads.Connect; global using HttpsRichardy.Federation.Application.Payloads.Client; @@ -44,6 +45,7 @@ global using HttpsRichardy.Federation.WebApi.Attributes; global using HttpsRichardy.Federation.WebApi.Binders; global using HttpsRichardy.Federation.WebApi.Providers; +global using HttpsRichardy.Federation.WebApi.Workers; global using HttpsRichardy.Federation.WebApi.Constants; global using HttpsRichardy.Federation.WebApi.Conventions; diff --git a/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Workers/KeyRotationBackgroundService.cs b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Workers/KeyRotationBackgroundService.cs new file mode 100644 index 0000000..f7e6c9f --- /dev/null +++ b/Applications/Backend/Source/HttpsRichardy.Federation.WebApi/Workers/KeyRotationBackgroundService.cs @@ -0,0 +1,54 @@ +namespace HttpsRichardy.Federation.WebApi.Workers; + +public sealed class KeyRotationBackgroundService(IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService +{ + private static readonly TimeSpan _rotationInterval = TimeSpan.FromHours(24); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + using var scope = scopeFactory.CreateScope(); + + var rotationService = scope.ServiceProvider.GetRequiredService(); + var realmCollection = scope.ServiceProvider.GetRequiredService(); + var secretCollection = scope.ServiceProvider.GetRequiredService(); + + var realms = await realmCollection.GetRealmsAsync(RealmFilters.WithoutFilters, stoppingToken); + + await Parallel.ForEachAsync(realms, stoppingToken, async (realm, cancellation) => + { + try + { + logger.LogInformation("rotating keys for realm {realm}", realm.Name); + + await rotationService.EnsureSecretExistsAsync(realm, cancellation); + + var now = DateTime.UtcNow; + var filters = SecretFilters.WithSpecifications() + .WithRealm(realm.Id) + .WithCanSign(now) + .Build(); + + var secrets = await secretCollection.GetSecretsAsync(filters, cancellation); + var current = secrets + .OrderByDescending(secret => secret.CreatedAt) + .FirstOrDefault(); + + if (current is not null && now - current.CreatedAt >= _rotationInterval) + { + await rotationService.RotateSecretAsync(realm, cancellation); + } + + await rotationService.PruneSecretsAsync(realm, cancellation); + } + catch (Exception exception) + { + logger.LogError(exception, "an error occurred while rotating keys for realm {realm}", realm.Name); + } + }); + + await Task.Delay(_rotationInterval, stoppingToken); + } + } +} diff --git a/Applications/Backend/Tests/Integration/Endpoints/RealmEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/RealmEndpointTests.cs index fa84b6f..5ecae4e 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/RealmEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/RealmEndpointTests.cs @@ -447,4 +447,170 @@ public async Task WhenDeleteRealmPermissionWithNonExistentPermission_ShouldRetur Assert.Equal(HttpStatusCode.NotFound, httpResponse.StatusCode); Assert.Equal(PermissionErrors.PermissionDoesNotExist, error); } + + [Fact(DisplayName = "[e2e] - when GET /realms/{id}/secrets should return realm's active secrets")] + public async Task WhenGetRealmSecrets_ShouldReturnActiveSecrets() + { + /* arrange: authenticate user and get access token */ + var httpClient = factory.HttpClient.WithRealmHeader("master"); + var credentials = new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }; + + var authenticationResponse = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", credentials); + var authenticationResult = await authenticationResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(authenticationResult); + Assert.NotEmpty(authenticationResult.AccessToken); + + httpClient.WithAuthorization(authenticationResult.AccessToken); + + /* arrange: create a new realm */ + var realmPayload = _fixture.Build() + .With(realm => realm.Name, $"test-realm-{Guid.NewGuid()}") + .Create(); + + var realmResponse = await httpClient.PostAsJsonAsync("api/v1/realms", realmPayload); + var realm = await realmResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(realm); + Assert.Equal(HttpStatusCode.Created, realmResponse.StatusCode); + + /* act: send GET request to retrieve realm's secrets */ + var getResponse = await httpClient.GetAsync($"api/v1/realms/{realm.Id}/secrets"); + var secrets = await getResponse.Content.ReadFromJsonAsync>(); + + /* assert: response should be 200 OK */ + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + Assert.NotNull(secrets); + + /* assert: should have at least one active secret */ + Assert.NotEmpty(secrets); + + /* assert: verify secret structure (no private/public key values) */ + foreach (var secret in secrets) + { + Assert.NotNull(secret.Id); + Assert.True(secret.CreatedAt != default); + } + } + + [Fact(DisplayName = "[e2e] - when GET /realms/{id}/secrets with non-existent realm should return 404 #ERROR-2FB9A")] + public async Task WhenGetRealmSecretsWithNonExistentRealm_ShouldReturnNotFound() + { + /* arrange: authenticate user and get access token */ + var httpClient = factory.HttpClient.WithRealmHeader("master"); + var credentials = new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }; + + var authenticationResponse = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", credentials); + var authenticationResult = await authenticationResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(authenticationResult); + Assert.NotEmpty(authenticationResult.AccessToken); + + httpClient.WithAuthorization(authenticationResult.AccessToken); + + /* arrange: prepare request with a non-existent realm ID */ + var nonExistentRealmId = Guid.NewGuid().ToString(); + + /* act: send GET request for non-existent realm's secrets */ + var response = await httpClient.GetAsync($"api/v1/realms/{nonExistentRealmId}/secrets"); + var error = await response.Content.ReadFromJsonAsync(); + + /* assert: response should be 404 Not Found */ + Assert.NotNull(error); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(RealmErrors.RealmDoesNotExist.Code, error.Code); + } + + [Fact(DisplayName = "[e2e] - when POST /realms/{id}/secrets/rotate should rotate secrets successfully")] + public async Task WhenPostRealmSecretsRotate_ShouldRotateSecretsSuccessfully() + { + /* arrange: authenticate user and get access token */ + var httpClient = factory.HttpClient.WithRealmHeader("master"); + var credentials = new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }; + + var authenticationResponse = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", credentials); + var authenticationResult = await authenticationResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(authenticationResult); + Assert.NotEmpty(authenticationResult.AccessToken); + + httpClient.WithAuthorization(authenticationResult.AccessToken); + + /* arrange: create a new realm */ + var realmPayload = _fixture.Build() + .With(realm => realm.Name, $"test-realm-{Guid.NewGuid()}") + .Create(); + + var realmResponse = await httpClient.PostAsJsonAsync("api/v1/realms", realmPayload); + var realm = await realmResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(realm); + Assert.Equal(HttpStatusCode.Created, realmResponse.StatusCode); + + /* arrange: get secrets before rotation */ + var getBeforeResponse = await httpClient.GetAsync($"api/v1/realms/{realm.Id}/secrets"); + var secretsBefore = await getBeforeResponse.Content.ReadFromJsonAsync>(); + + Assert.NotNull(secretsBefore); + var initialSecretCount = secretsBefore.Count; + + /* act: send POST request to rotate secrets */ + var rotateResponse = await httpClient.PostAsJsonAsync($"api/v1/realms/{realm.Id}/secrets/rotate", new { }); + + /* assert: response should be 204 No Content */ + Assert.Equal(HttpStatusCode.NoContent, rotateResponse.StatusCode); + + /* assert: verify new secret was created */ + var getAfterResponse = await httpClient.GetAsync($"api/v1/realms/{realm.Id}/secrets"); + var secretsAfter = await getAfterResponse.Content.ReadFromJsonAsync>(); + + Assert.NotNull(secretsAfter); + Assert.True(secretsAfter.Count >= initialSecretCount, "New secret should be created after rotation"); + } + + [Fact(DisplayName = "[e2e] - when POST /realms/{id}/secrets/rotate with non-existent realm should return 404 #ERROR-2FB9A")] + public async Task WhenPostRealmSecretsRotateWithNonExistentRealm_ShouldReturnNotFound() + { + /* arrange: authenticate user and get access token */ + var httpClient = factory.HttpClient.WithRealmHeader("master"); + var credentials = new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }; + + var authenticationResponse = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", credentials); + var authenticationResult = await authenticationResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(authenticationResult); + Assert.NotEmpty(authenticationResult.AccessToken); + + httpClient.WithAuthorization(authenticationResult.AccessToken); + + /* arrange: prepare request with a non-existent realm ID */ + var nonExistentRealmId = Guid.NewGuid().ToString(); + + /* act: send POST request to rotate secrets for non-existent realm */ + var response = await httpClient.PostAsJsonAsync($"api/v1/realms/{nonExistentRealmId}/secrets/rotate", new { }); + var error = await response.Content.ReadFromJsonAsync(); + + /* assert: response should be 404 Not Found */ + Assert.NotNull(error); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(RealmErrors.RealmDoesNotExist, error); + } } diff --git a/Applications/Backend/Tests/Integration/Endpoints/WellKnownEndpointTests.cs b/Applications/Backend/Tests/Integration/Endpoints/WellKnownEndpointTests.cs index edd4bb1..281f628 100644 --- a/Applications/Backend/Tests/Integration/Endpoints/WellKnownEndpointTests.cs +++ b/Applications/Backend/Tests/Integration/Endpoints/WellKnownEndpointTests.cs @@ -10,7 +10,7 @@ public async Task WhenGetOpenIdConfiguration_ShouldReturnConfiguration() var httpClient = factory.HttpClient; /* act: send GET request to open id configuration endpoint */ - var response = await httpClient.GetAsync(".well-known/openid-configuration"); + var response = await httpClient.GetAsync("master/.well-known/openid-configuration"); var configuration = await response.Content.ReadFromJsonAsync(); /* assert: response should be 200 OK */ @@ -33,7 +33,7 @@ public async Task WhenGetOpenIdConfiguration_ShouldIncludeSupportedResponseTypes var httpClient = factory.HttpClient; /* act: send GET request to OpenID configuration endpoint */ - var response = await httpClient.GetAsync(".well-known/openid-configuration"); + var response = await httpClient.GetAsync("master/.well-known/openid-configuration"); var configuration = await response.Content.ReadFromJsonAsync(); /* assert: response should be 200 OK */ @@ -52,7 +52,7 @@ public async Task WhenGetOpenIdConfiguration_ShouldIncludeSubjectTypes() var httpClient = factory.HttpClient; /* act: send GET request to OpenID configuration endpoint */ - var response = await httpClient.GetAsync(".well-known/openid-configuration"); + var response = await httpClient.GetAsync("master/.well-known/openid-configuration"); var configuration = await response.Content.ReadFromJsonAsync(); /* assert: response should be 200 OK */ @@ -71,7 +71,7 @@ public async Task WhenGetOpenIdConfiguration_ShouldIncludeSigningAlgorithms() var httpClient = factory.HttpClient; /* act: send GET request to OpenID configuration endpoint */ - var response = await httpClient.GetAsync(".well-known/openid-configuration"); + var response = await httpClient.GetAsync("master/.well-known/openid-configuration"); var configuration = await response.Content.ReadFromJsonAsync(); /* assert: response should be 200 OK */ @@ -91,7 +91,7 @@ public async Task WhenGetJsonWebKeys_ShouldReturnJwks() var httpClient = factory.HttpClient; /* act: send GET request to JWKS endpoint */ - var response = await httpClient.GetAsync(".well-known/jwks.json"); + var response = await httpClient.GetAsync("master/.well-known/jwks.json"); var jwks = await response.Content.ReadFromJsonAsync(); /* assert: response should be 200 OK */ @@ -109,7 +109,7 @@ public async Task WhenGetJsonWebKeys_ShouldReturnKeysWithRequiredProperties() var httpClient = factory.HttpClient; /* act: send GET request to JWKS endpoint */ - var response = await httpClient.GetAsync(".well-known/jwks.json"); + var response = await httpClient.GetAsync("master/.well-known/jwks.json"); var jwks = await response.Content.ReadFromJsonAsync(); /* assert: response should be 200 OK */ @@ -143,10 +143,10 @@ public async Task WhenGetJsonWebKeysMultipleTimes_ShouldReturnConsistentKeys() var httpClient = factory.HttpClient; /* act: send GET request twice to JWKS endpoint */ - var firstResponse = await httpClient.GetAsync(".well-known/jwks.json"); + var firstResponse = await httpClient.GetAsync("master/.well-known/jwks.json"); var firstJwks = await firstResponse.Content.ReadFromJsonAsync(); - var secondResponse = await httpClient.GetAsync(".well-known/jwks.json"); + var secondResponse = await httpClient.GetAsync("master/.well-known/jwks.json"); var secondJwks = await secondResponse.Content.ReadFromJsonAsync(); /* assert: both responses should be 200 OK */ @@ -168,7 +168,7 @@ public async Task WhenGetOpenIdConfiguration_ShouldHaveMatchingJwksUri() var httpClient = factory.HttpClient; /* act: send GET request to OpenID configuration endpoint */ - var response = await httpClient.GetAsync(".well-known/openid-configuration"); + var response = await httpClient.GetAsync("master/.well-known/openid-configuration"); var configuration = await response.Content.ReadFromJsonAsync(); /* assert: response should be 200 OK */ @@ -192,7 +192,7 @@ public async Task WhenGetOpenIdConfiguration_ShouldHaveValidEndpointUrls() var httpClient = factory.HttpClient; /* act: send GET request to OpenID configuration endpoint */ - var response = await httpClient.GetAsync(".well-known/openid-configuration"); + var response = await httpClient.GetAsync("master/.well-known/openid-configuration"); var configuration = await response.Content.ReadFromJsonAsync(); /* assert: response should be 200 OK */ diff --git a/Applications/Backend/Tests/Integration/Security/AuthenticationServiceTests.cs b/Applications/Backend/Tests/Integration/Security/AuthenticationServiceTests.cs index 9d03ee6..bc817cd 100644 --- a/Applications/Backend/Tests/Integration/Security/AuthenticationServiceTests.cs +++ b/Applications/Backend/Tests/Integration/Security/AuthenticationServiceTests.cs @@ -15,6 +15,7 @@ public sealed class AuthenticationServiceTests : private readonly Mock _realmProvider = new(); private readonly Mock _hostProvider = new(); private readonly Mock _secretCollection = new(); + private readonly Mock _secretRotationService = new(); private readonly Mock _groupCollection = new(); public AuthenticationServiceTests(MongoDatabaseFixture mongoFixture) @@ -26,17 +27,20 @@ public AuthenticationServiceTests(MongoDatabaseFixture mongoFixture) _passwordHasher = new PasswordHasher(); var tokenCollection = new TokenCollection(_database, _realmProvider.Object); + var realm = _fixture.Create(); var secret = new Secret { + Id = Identifier.Generate(), + RealmId = realm.Id, PrivateKey = Convert.ToBase64String(_rsa.ExportRSAPrivateKey()), - PublicKey = Convert.ToBase64String(_rsa.ExportRSAPublicKey()) + PublicKey = Convert.ToBase64String(_rsa.ExportRSAPublicKey()), + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(30) }; - var realm = _fixture.Create(); - _secretCollection - .Setup(collection => collection.GetSecretAsync(It.IsAny())) - .ReturnsAsync(secret); + .Setup(collection => collection.GetSecretsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([secret]); _groupCollection .Setup(collection => collection.GetGroupsAsync(It.IsAny(), It.IsAny())) @@ -53,6 +57,7 @@ public AuthenticationServiceTests(MongoDatabaseFixture mongoFixture) tokenCollection: tokenCollection, realmProvider: _realmProvider.Object, groupCollection: _groupCollection.Object, + secretRotationService: _secretRotationService.Object, host: _hostProvider.Object ); diff --git a/Applications/Backend/Tests/Integration/Security/JwtSecurityTokenServiceTests.cs b/Applications/Backend/Tests/Integration/Security/JwtSecurityTokenServiceTests.cs index 61151ed..cc75eee 100644 --- a/Applications/Backend/Tests/Integration/Security/JwtSecurityTokenServiceTests.cs +++ b/Applications/Backend/Tests/Integration/Security/JwtSecurityTokenServiceTests.cs @@ -11,6 +11,7 @@ public sealed class JwtSecurityTokenServiceTests : IClassFixture _realmProvider = new(); private readonly Mock _secretCollection = new(); + private readonly Mock _secretRotationService = new(); private readonly Mock _hostProvider = new(); private readonly Mock _groupCollection = new(); @@ -27,8 +28,12 @@ public JwtSecurityTokenServiceTests(MongoDatabaseFixture fixture) var realm = _fixture.Create(); var secret = new Secret { + Id = Identifier.Generate(), + RealmId = realm.Id, PrivateKey = Convert.ToBase64String(_rsa.ExportRSAPrivateKey()), - PublicKey = Convert.ToBase64String(_rsa.ExportRSAPublicKey()) + PublicKey = Convert.ToBase64String(_rsa.ExportRSAPublicKey()), + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(30) }; _realmProvider.Setup(provider => provider.GetCurrentRealm()) @@ -39,13 +44,14 @@ public JwtSecurityTokenServiceTests(MongoDatabaseFixture fixture) .ReturnsAsync([]); _secretCollection - .Setup(collection => collection.GetSecretAsync(It.IsAny())) - .ReturnsAsync(secret); + .Setup(collection => collection.GetSecretsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([secret]); _jwtSecurityTokenService = new JwtSecurityTokenService( realmProvider: _realmProvider.Object, secretCollection: _secretCollection.Object, groupCollection: _groupCollection.Object, + secretRotationService: _secretRotationService.Object, tokenCollection: _tokenCollection, host: _hostProvider.Object ); @@ -132,7 +138,13 @@ public async Task WhenValidatingExpiredToken_ThenItMustReturnTokenExpiredError() { /* arrange: create an expired token */ - var secret = await _secretCollection.Object.GetSecretAsync(); + var secret = new Secret + { + Id = Identifier.Generate(), + PrivateKey = Convert.ToBase64String(_rsa.ExportRSAPrivateKey()), + PublicKey = Convert.ToBase64String(_rsa.ExportRSAPublicKey()) + }; + var privateKey = Common.Utilities.RsaHelper.CreateSecurityKeyFromPrivateKey(secret.PrivateKey); var tokenHandler = new JwtSecurityTokenHandler(); diff --git a/Applications/Backend/Tests/Integration/Security/KeyRotationIntegrationTests.cs b/Applications/Backend/Tests/Integration/Security/KeyRotationIntegrationTests.cs new file mode 100644 index 0000000..dd4500d --- /dev/null +++ b/Applications/Backend/Tests/Integration/Security/KeyRotationIntegrationTests.cs @@ -0,0 +1,344 @@ +namespace HttpsRichardy.Federation.TestSuite.Integration.Security; + +public sealed class KeyRotationIntegrationTests(IntegrationEnvironmentFixture factory) : + IClassFixture +{ + [Fact(DisplayName = "[e2e] - when rotating keys should publish new kid on realm jwks")] + public async Task WhenRotateKeys_ShouldPublishNewKidOnRealmJwks() + { + var httpClient = factory.HttpClient.WithRealmHeader("master"); + var rotationService = factory.Services.GetRequiredService(); + + var authBefore = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }); + + var authentication = await authBefore.Content.ReadFromJsonAsync(); + + Assert.NotNull(authentication); + Assert.False(string.IsNullOrWhiteSpace(authentication.AccessToken)); + + var handlerBefore = new JwtSecurityTokenHandler(); + + var jwtBefore = handlerBefore.ReadJwtToken(authentication.AccessToken); + var kidBefore = jwtBefore.Header.Kid; + + Assert.False(string.IsNullOrWhiteSpace(kidBefore)); + + var realmCollection = factory.Services.GetRequiredService(); + var realmFilters = RealmFilters.WithSpecifications() + .WithName("master") + .Build(); + + var realms = await realmCollection.GetRealmsAsync(realmFilters, CancellationToken.None); + var realm = realms.FirstOrDefault(); + + Assert.NotNull(realm); + + await rotationService.RotateSecretAsync(realm, CancellationToken.None); + + string? kidAfter = null; + + var started = DateTimeOffset.UtcNow; + + while (DateTimeOffset.UtcNow - started < TimeSpan.FromSeconds(10)) + { + var authAfter = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }); + + var authenticationResult = await authAfter.Content.ReadFromJsonAsync(); + + Assert.NotNull(authenticationResult); + + var handlerAfter = new JwtSecurityTokenHandler(); + var jwtAfter = handlerAfter.ReadJwtToken(authenticationResult.AccessToken); + + kidAfter = jwtAfter.Header.Kid; + + if (!string.Equals(kidAfter, kidBefore, StringComparison.Ordinal)) + break; + + await Task.Delay(300); + } + + Assert.False(string.IsNullOrWhiteSpace(kidAfter)); + Assert.NotEqual(kidBefore, kidAfter); + + var jwksResponse = await httpClient.GetAsync("master/.well-known/jwks.json"); + var jwksRaw = await jwksResponse.Content.ReadAsStringAsync(); + + using var jwks = JsonDocument.Parse(jwksRaw); + + Assert.True(jwks.RootElement.TryGetProperty("keys", out var keys)); + Assert.Equal(JsonValueKind.Array, keys.ValueKind); + + var jwksKids = keys.EnumerateArray() + .Where(key => key.TryGetProperty("kid", out _)) + .Select(key => key.GetProperty("kid").GetString()) + .Where(key => !string.IsNullOrWhiteSpace(key)) + .Cast() + .ToArray(); + + Assert.Contains(kidAfter!, jwksKids); + } + + [Fact(DisplayName = "[e2e] - when rotating keys should keep old token valid while old key is published")] + public async Task WhenRotateKeys_ShouldKeepOldTokenValidWhileOldKeyIsPublished() + { + var httpClient = factory.HttpClient.WithRealmHeader("master"); + + var rotationService = factory.Services.GetRequiredService(); + var realmCollection = factory.Services.GetRequiredService(); + + var authBefore = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }); + + var authentication = await authBefore.Content.ReadFromJsonAsync(); + + Assert.NotNull(authentication); + Assert.NotEmpty(authentication.AccessToken); + + var oldToken = authentication.AccessToken; + var oldJwt = new JwtSecurityTokenHandler().ReadJwtToken(oldToken); + var oldKid = oldJwt.Header.Kid; + + Assert.False(string.IsNullOrWhiteSpace(oldKid)); + + var realmFilters = RealmFilters.WithSpecifications() + .WithName("master") + .Build(); + + var realms = await realmCollection.GetRealmsAsync(realmFilters, CancellationToken.None); + var realm = realms.FirstOrDefault(); + + Assert.NotNull(realm); + + await rotationService.RotateSecretAsync(realm, CancellationToken.None); + + string? newKid = null; + var started = DateTimeOffset.UtcNow; + + while (DateTimeOffset.UtcNow - started < TimeSpan.FromSeconds(10)) + { + var authAfter = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }); + + var authPayloadAfter = await authAfter.Content.ReadFromJsonAsync(); + + Assert.NotNull(authPayloadAfter); + + var jwtAfter = new JwtSecurityTokenHandler().ReadJwtToken(authPayloadAfter.AccessToken); + + newKid = jwtAfter.Header.Kid; + + if (!string.Equals(oldKid, newKid, StringComparison.Ordinal)) + break; + + await Task.Delay(300); + } + + Assert.False(string.IsNullOrWhiteSpace(newKid)); + + var jwksResponse = await httpClient.GetAsync("master/.well-known/jwks.json"); + var jwksRaw = await jwksResponse.Content.ReadAsStringAsync(); + + using var jwks = JsonDocument.Parse(jwksRaw); + + Assert.True(jwks.RootElement.TryGetProperty("keys", out var keys)); + + var signingKeys = keys.EnumerateArray() + .Select(key => new JsonWebKey(key.GetRawText()) as SecurityKey) + .ToArray(); + + var validationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKeys = signingKeys, + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + RequireSignedTokens = true + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + var validationSucceeded = true; + + try + { + tokenHandler.ValidateToken(oldToken, validationParameters, out _); + } + catch + { + validationSucceeded = false; + } + + Assert.True(validationSucceeded); + } + + [Fact(DisplayName = "[e2e] - when rotating keys jwks should contain multiple keys during grace period")] + public async Task WhenRotateKeys_JwksShouldContainMultipleKeysDuringGracePeriod() + { + var httpClient = factory.HttpClient.WithRealmHeader("master"); + + var rotationService = factory.Services.GetRequiredService(); + var realmCollection = factory.Services.GetRequiredService(); + + var jwksResponseBefore = await httpClient.GetAsync("master/.well-known/jwks.json"); + var jwksRawBefore = await jwksResponseBefore.Content.ReadAsStringAsync(); + + using var jwksBefore = JsonDocument.Parse(jwksRawBefore); + + Assert.NotNull(jwksBefore); + Assert.True(jwksBefore.RootElement.TryGetProperty("keys", out var keysBefore)); + + var oldKidsCount = keysBefore.EnumerateArray() + .Where(key => key.TryGetProperty("kid", out _)) + .Count(); + + var realmFilters = RealmFilters.WithSpecifications() + .WithName("master") + .Build(); + + var realms = await realmCollection.GetRealmsAsync(realmFilters, CancellationToken.None); + var realm = realms.FirstOrDefault(); + + Assert.NotNull(realm); + + await rotationService.RotateSecretAsync(realm, CancellationToken.None); + + string? newKid = null; + var started = DateTimeOffset.UtcNow; + + while (DateTimeOffset.UtcNow - started < TimeSpan.FromSeconds(10)) + { + var response = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }); + + var authentication = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(authentication); + Assert.NotEmpty(authentication.AccessToken); + + var jwt = new JwtSecurityTokenHandler().ReadJwtToken(authentication.AccessToken); + + newKid = jwt.Header.Kid; + + var jwksResponse = await httpClient.GetAsync("master/.well-known/jwks.json"); + var jwksRaw = await jwksResponse.Content.ReadAsStringAsync(); + + using var jwks = JsonDocument.Parse(jwksRaw); + + Assert.True(jwks.RootElement.TryGetProperty("keys", out var keysAfter)); + + var newKidsCount = keysAfter.EnumerateArray() + .Where(key => key.TryGetProperty("kid", out _)) + .Count(); + + if (newKidsCount > oldKidsCount) + { + var jwksKids = keysAfter.EnumerateArray() + .Where(key => key.TryGetProperty("kid", out _)) + .Select(key => key.GetProperty("kid").GetString()) + .Where(key => !string.IsNullOrWhiteSpace(key)) + .Cast() + .ToArray(); + + Assert.NotEmpty(jwksKids); + Assert.True(jwksKids.Length >= 2, "JWKS should contain at least 2 keys during grace period"); + + return; + } + + await Task.Delay(300); + } + + Assert.Fail("JWKS never showed multiple keys during grace period"); + } + + [Fact(DisplayName = "[e2e] - when grace period expires old key should be removed from database")] + public async Task WhenGracePeriodExpires_ShouldRemoveOldKeyFromDatabase() + { + var httpClient = factory.HttpClient.WithRealmHeader("master"); + + var rotationService = factory.Services.GetRequiredService(); + var realmCollection = factory.Services.GetRequiredService(); + var secretCollection = factory.Services.GetRequiredService(); + + var realmFilters = RealmFilters.WithSpecifications() + .WithName("master") + .Build(); + + var realms = await realmCollection.GetRealmsAsync(realmFilters, CancellationToken.None); + var realm = realms.FirstOrDefault(); + + Assert.NotNull(realm); + + var secretFiltersBefore = SecretFilters.WithSpecifications() + .WithRealm(realm.Id) + .Build(); + + var secretsBefore = await secretCollection.GetSecretsAsync(secretFiltersBefore, CancellationToken.None); + var countBefore = secretsBefore.Count; + + await rotationService.RotateSecretAsync(realm, CancellationToken.None); + + var started = DateTimeOffset.UtcNow; + while (DateTimeOffset.UtcNow - started < TimeSpan.FromSeconds(10)) + { + var auth = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }); + + var authResult = await auth.Content.ReadFromJsonAsync(); + + Assert.NotNull(authResult); + + var secretsAfterRotation = await secretCollection.GetSecretsAsync(secretFiltersBefore, CancellationToken.None); + var countAfterRotation = secretsAfterRotation.Count; + + if (countAfterRotation > countBefore) + { + var oldSecret = secretsAfterRotation.FirstOrDefault(secret => secret.GracePeriodEndsAt is not null); + + Assert.NotNull(oldSecret); + Assert.NotNull(oldSecret.GracePeriodEndsAt); + + oldSecret.GracePeriodEndsAt = DateTime.UtcNow.AddSeconds(-1); + + await secretCollection.UpdateAsync(oldSecret, cancellation: CancellationToken.None); + await rotationService.PruneSecretsAsync(realm, CancellationToken.None); + + var secretsAfterPrune = await secretCollection.GetSecretsAsync(secretFiltersBefore, CancellationToken.None); + var countAfterPrune = secretsAfterPrune.Count; + + Assert.True(countAfterPrune <= countAfterRotation); + + var removedSecret = secretsAfterPrune.FirstOrDefault(secret => secret.Id == oldSecret.Id); + + Assert.Null(removedSecret); + + return; + } + + await Task.Delay(300); + } + + Assert.Fail("Grace period test timeout: rotation did not complete in time"); + } +} diff --git a/Applications/Backend/Tests/Usings.cs b/Applications/Backend/Tests/Usings.cs index 4353b94..0733770 100644 --- a/Applications/Backend/Tests/Usings.cs +++ b/Applications/Backend/Tests/Usings.cs @@ -1,6 +1,7 @@ global using System.Net; global using System.Net.Http.Headers; global using System.Net.Http.Json; +global using System.Text.Json; global using System.IdentityModel.Tokens.Jwt; global using System.Security.Cryptography; @@ -28,6 +29,7 @@ global using HttpsRichardy.Federation.Application.Payloads.Realm; global using HttpsRichardy.Federation.Application.Payloads.Permission; global using HttpsRichardy.Federation.Application.Payloads.Group; +global using HttpsRichardy.Federation.Application.Payloads.Secret; global using HttpsRichardy.Federation.Application.Payloads.Common; global using HttpsRichardy.Federation.Application.Payloads.Connect; global using HttpsRichardy.Federation.Application.Payloads.Client; diff --git a/CHANGELOG b/CHANGELOG index 038c5eb..8f4a9a7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,9 @@ +# 4.1.0 - 2026-04-24 + +this release introduces per-realm key rotation, allowing each realm to manage and rotate its own signing keys independently. + +we also made all .well-known endpoints realm-specific. Accessing these endpoints now requires a realm name as part of the request, ensuring configuration and discovery metadata are resolved within the correct realm context. + # 4.0.0 - 2026-04-20 this release introduces full multi-client support per realm. In previous versions, a realm (tenant) was effectively treated as a single client, but starting in 4.0.0 each realm can manage multiple clients with their own credentials, permissions, flows, redirect uris, and audiences. We also added dedicated client management capabilities and support for multiple token audiences during issuance and validation. diff --git a/Packages/Federation.Sdk/Source/Extensions/AuthenticationExtension.cs b/Packages/Federation.Sdk/Source/Extensions/AuthenticationExtension.cs index dc8dee3..38f55f8 100644 --- a/Packages/Federation.Sdk/Source/Extensions/AuthenticationExtension.cs +++ b/Packages/Federation.Sdk/Source/Extensions/AuthenticationExtension.cs @@ -11,6 +11,7 @@ public static void AddBearerAuthentication(this IServiceCollection services) .AddJwtBearer(configuration => { configuration.Authority = options.Authority; + configuration.MetadataAddress = $"{options.Authority}/{options.Realm}/.well-known/openid-configuration"; configuration.RequireHttpsMetadata = false; configuration.TokenValidationParameters = new TokenValidationParameters {