Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
6313008
feature(#22): this commit introduces realm-id and expiration dates to…
https-richardy Apr 21, 2026
1a78efc
feature(#22): this commit includes filtering classes to simplify the …
https-richardy Apr 21, 2026
2162300
feature(#22): this commit introduces the `secret` class with field co…
https-richardy Apr 21, 2026
215ae83
feature(#22): this commit introduces secret filters stage for dynamic…
https-richardy Apr 21, 2026
eb7c9ac
refactor(#22): this commit refactors the secrets collection with filt…
https-richardy Apr 21, 2026
1a6f28e
refactor(#22): this commit introduces static properties, making it ea…
https-richardy Apr 21, 2026
51552fa
feature(#22): Implements secret rotation by introducing the secret ro…
https-richardy Apr 21, 2026
4169206
feature(#22): this commit renames the “for realm” method to “with realm”
https-richardy Apr 21, 2026
03346ed
feature(#22): this commit includes the registration of the key rotati…
https-richardy Apr 21, 2026
3d50a65
feature(#22): this commit removes the initial secrets extension; secr…
https-richardy Apr 21, 2026
5d80c84
refactor(#22): this commit adjusts the filter classes
https-richardy Apr 21, 2026
889b78e
feature(#22): this commit implements “prune secrets” in the secret ro…
https-richardy Apr 21, 2026
3ac7d7d
feature(#22): this commit implements a background key rotation servic…
https-richardy Apr 21, 2026
dabea37
feature(#22): this commit updates well known controller to include re…
https-richardy Apr 21, 2026
c52206d
feature(#22): this commit updates secret handling in authentication t…
https-richardy Apr 21, 2026
a10f864
feature(#22): this commit introduces realm property in records classes
https-richardy Apr 21, 2026
2c089c3
feature(#22): this commit introduces can validate filter to secret fi…
https-richardy Apr 21, 2026
95ea97e
feature(#22): this commit introduces "workers" namespace to usings fo…
https-richardy Apr 21, 2026
01afe36
feature(#22): enhance JWT authentication to resolve signing keys base…
https-richardy Apr 21, 2026
ae27133
feature(#22): this commit introduces workers extension to register "k…
https-richardy Apr 21, 2026
cd4f978
refactor(#22): this commit updates method to accept IReadOnlyCollecti…
https-richardy Apr 21, 2026
e6d2f63
feature(#22): this commit updates token validation to support multipl…
https-richardy Apr 21, 2026
57135a8
feature(#22): this commit updates handler to include realm collection…
https-richardy Apr 21, 2026
7f6ee00
feature(#22): this commit update endpoint tests to include 'master' p…
https-richardy Apr 21, 2026
640e42f
feature(#22): this commit updates method to include realm for JWKS UR…
https-richardy Apr 21, 2026
83e9373
feature(#22): this commit updates handler to include realm collection…
https-richardy Apr 21, 2026
a6c2951
feature(#22): this commit refactor to include secret rotation service…
https-richardy Apr 21, 2026
d46793b
feature(#22): this commit removes the private `scope factory` field a…
https-richardy Apr 21, 2026
4ebbd5e
tests(#22): this commit introduces tests for signature key rotation
https-richardy Apr 21, 2026
75ef229
feature(#22): this commit updates the secret expiration logic
https-richardy Apr 21, 2026
fe4d115
feature(#22): this commit adds end-to-end tests for key rotation and …
https-richardy Apr 21, 2026
495920d
feature(#22): this commit makes System.Text.Json a global using state…
https-richardy Apr 21, 2026
34ee80c
feature(#22): this commit introduces a handler to retrieve secrets fr…
https-richardy Apr 21, 2026
df5c28d
feature(#22): this commit introduces a “secret mapper” with an extens…
https-richardy Apr 21, 2026
d302ef2
feature(#22): this commit introduces the schemes for retrieving secre…
https-richardy Apr 21, 2026
d62ea64
feature(#22): this commit introduces the “secret scheme” class for se…
https-richardy Apr 21, 2026
5c78940
feature(#22): this commit includes new “global using” directives that…
https-richardy Apr 21, 2026
d5afab9
feature(#22): this commit introduces validation to ensure that the re…
https-richardy Apr 21, 2026
659c70e
feature(#22): this commit introduces an endpoint to list secrets in a…
https-richardy Apr 21, 2026
4095c35
feature(#22): this commit introduces a handler for rotating realm sec…
https-richardy Apr 21, 2026
821ac92
feature(#22): this commit introduces an endpoint for rotating realm s…
https-richardy Apr 21, 2026
df8d15e
feature(#22): secrets are now sorted by the `created at` field in des…
https-richardy Apr 21, 2026
8e18f8e
feature(#22): this commit includes four integration tests for the rea…
https-richardy Apr 22, 2026
daf820b
feature(#22): this commit introduces secret rotation service integrat…
https-richardy Apr 22, 2026
329b899
feature(#22): this commit introduces metadata address configuration f…
https-richardy Apr 22, 2026
d71cdf7
feature(#22): enhance key rotation logic to ensure valid signing keys…
https-richardy Apr 22, 2026
f057b53
feature(#22): thiss commit updates CHANGELOG for 4.1.0 release, addin…
https-richardy Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<FetchJsonWebKeysParameters, Result<JsonWebKeySetScheme>>
{
public async Task<Result<JsonWebKeySetScheme>> 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<JsonWebKeySetScheme>.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<JsonWebKeySetScheme>.Success(jwks);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<FetchOpenIDConfigurationParameters, Result<OpenIDConfigurationScheme>>
{
public Task<Result<OpenIDConfigurationScheme>> HandleAsync(
public async Task<Result<OpenIDConfigurationScheme>> HandleAsync(
FetchOpenIDConfigurationParameters parameters, CancellationToken cancellation = default)
{
var configuration = ConnectMapper.AsConfiguration(host.Address);
var realmFilters = RealmFilters.WithSpecifications()
.WithName(parameters.Realm)
.Build();

return Task.FromResult(Result<OpenIDConfigurationScheme>.Success(configuration));
var realms = await realmCollection.GetRealmsAsync(realmFilters, cancellation);
var realm = realms.FirstOrDefault();

if (realm is null)
{
return Result<OpenIDConfigurationScheme>.Failure(RealmErrors.RealmDoesNotExist);
}

var configuration = ConnectMapper.AsConfiguration(host.Address, parameters.Realm);

return Result<OpenIDConfigurationScheme>.Success(configuration);
}
}
Original file line number Diff line number Diff line change
@@ -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<RealmCreationScheme, Result<RealmDetailsScheme>>
{
public async Task<Result<RealmDetailsScheme>> HandleAsync(
Expand Down Expand Up @@ -31,6 +34,7 @@ public async Task<Result<RealmDetailsScheme>> HandleAsync(
.ToList();

await collection.InsertAsync(realm, cancellation: cancellation);
await secretRotationService.EnsureSecretExistsAsync(realm, cancellation);

return Result<RealmDetailsScheme>.Success(RealmMapper.AsResponse(realm));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace HttpsRichardy.Federation.Application.Handlers.Secret;

public sealed class FetchRealmSecretsHandler(ISecretCollection collection, IRealmCollection realmCollection) :
IDispatchHandler<FetchRealmSecretsParameters, Result<IReadOnlyCollection<SecretScheme>>>
{
public async Task<Result<IReadOnlyCollection<SecretScheme>>> 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<IReadOnlyCollection<SecretScheme>>.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<IReadOnlyCollection<SecretScheme>>.Success(schemes);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace HttpsRichardy.Federation.Application.Handlers.Secret;

public sealed class RotateRealmSecretsHandler(ISecretRotationService rotationService, IRealmCollection realmCollection) :
IDispatchHandler<RotateRealmSecretsParameters, Result>
{
public async Task<Result> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ public static JsonWebKeyScheme AsJsonWebKeys(Secret secret)
};
}

public static JsonWebKeySetScheme AsJsonWebKeySetScheme(Secret secret)
public static JsonWebKeySetScheme AsJsonWebKeySetScheme(IReadOnlyCollection<Secret> secrets)
{
return new JsonWebKeySetScheme
{
Keys = [JsonWebKeysMapper.AsJsonWebKeys(secret)]
Keys = [.. secrets.Select(secret => JsonWebKeysMapper.AsJsonWebKeys(secret))]
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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
};
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
namespace HttpsRichardy.Federation.Application.Payloads.Connect;

public sealed record FetchJsonWebKeysParameters : IDispatchable<Result<JsonWebKeySetScheme>>;
public sealed record FetchJsonWebKeysParameters : IDispatchable<Result<JsonWebKeySetScheme>>
{
public string Realm { get; init; } = default!;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
namespace HttpsRichardy.Federation.Application.Payloads.Connect;

public sealed record FetchOpenIDConfigurationParameters :
IDispatchable<Result<OpenIDConfigurationScheme>>;
IDispatchable<Result<OpenIDConfigurationScheme>>
{
public string Realm { get; init; } = default!;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace HttpsRichardy.Federation.Application.Payloads.Secret;

public sealed record FetchRealmSecretsParameters :
IDispatchable<Result<IReadOnlyCollection<SecretScheme>>>
{
public string RealmId { get; init; } = default!;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace HttpsRichardy.Federation.Application.Payloads.Secret;

public sealed record RotateRealmSecretsParameters :
IDispatchable<Result>
{
public string RealmId { get; init; } = default!;
}
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -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
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@ public sealed class Secret : Aggregate
{
public string PrivateKey { get; set; } = default!;
public string PublicKey { get; set; } = default!;
}
public string RealmId { get; set; } = default!;

public DateTime? ExpiresAt { get; set; }
public DateTime? GracePeriodEndsAt { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,13 @@ namespace HttpsRichardy.Federation.Domain.Collections;

public interface ISecretCollection : IAggregateCollection<Secret>
{
public Task<Secret> GetSecretAsync(CancellationToken cancellation = default);
public Task<IReadOnlyCollection<Secret>> GetSecretsAsync(
SecretFilters filters,
CancellationToken cancellation = default
);

public Task<System.Numerics.BigInteger> CountSecretsAsync(
SecretFilters filters,
CancellationToken cancellation = default
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
namespace HttpsRichardy.Federation.Domain.Filtering.Builders;

public sealed class SecretFiltersBuilder :
FiltersBuilderBase<SecretFilters, SecretFiltersBuilder>
{
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ public sealed class RealmFilters : Filters
{
public string? Name { get; set; }

public static RealmFilters WithoutFilters => new();
public static RealmFiltersBuilder WithSpecifications() => new();
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public static void AddServices(this IServiceCollection services)
services.AddTransient<IPasswordHasher, PasswordHasher>();
services.AddTransient<IAuthenticationService, AuthenticationService>();
services.AddTransient<ISecurityTokenService, JwtSecurityTokenService>();
services.AddTransient<ISecretRotationService, SecretRotationService>();
services.AddTransient<IClientCredentialsGenerator, ClientCredentialsGenerator>();

services.AddTransient<IRedirectUriPolicy, RedirectUriPolicy>();
Expand Down
Loading
Loading