Skip to content

Commit f4b47d6

Browse files
merge pull request #23 from https-richardy/feature/22-per-realm-secret-rotation
[#22] - implement per-realm JWT secret rotation with lifecycle management
2 parents 0bb30cc + f057b53 commit f4b47d6

44 files changed

Lines changed: 1234 additions & 96 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Connect/FetchJsonWebKeysHandler.cs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,30 @@
11
namespace HttpsRichardy.Federation.Application.Handlers.Connect;
22

3-
public sealed class FetchJsonWebKeysHandler(ISecretCollection collection) :
3+
public sealed class FetchJsonWebKeysHandler(ISecretCollection collection, IRealmCollection realmCollection) :
44
IDispatchHandler<FetchJsonWebKeysParameters, Result<JsonWebKeySetScheme>>
55
{
66
public async Task<Result<JsonWebKeySetScheme>> HandleAsync(
77
FetchJsonWebKeysParameters parameters, CancellationToken cancellation = default)
88
{
9-
var secret = await collection.GetSecretAsync(cancellation: cancellation);
10-
var jwks = JsonWebKeysMapper.AsJsonWebKeySetScheme(secret);
9+
var realmFilters = RealmFilters.WithSpecifications()
10+
.WithName(parameters.Realm)
11+
.Build();
12+
13+
var realms = await realmCollection.GetRealmsAsync(realmFilters, cancellation);
14+
var realm = realms.FirstOrDefault();
15+
16+
if (realm is null)
17+
{
18+
return Result<JsonWebKeySetScheme>.Failure(RealmErrors.RealmDoesNotExist);
19+
}
20+
21+
var filters = SecretFilters.WithSpecifications()
22+
.WithCanValidate()
23+
.WithRealm(realm.Id)
24+
.Build();
25+
26+
var secrets = await collection.GetSecretsAsync(filters, cancellation);
27+
var jwks = JsonWebKeysMapper.AsJsonWebKeySetScheme(secrets);
1128

1229
return Result<JsonWebKeySetScheme>.Success(jwks);
1330
}
Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
namespace HttpsRichardy.Federation.Application.Handlers.Connect;
22

3-
public sealed class FetchOpenIDConfigurationHandler(IHostInformationProvider host) :
3+
public sealed class FetchOpenIDConfigurationHandler(IHostInformationProvider host, IRealmCollection realmCollection) :
44
IDispatchHandler<FetchOpenIDConfigurationParameters, Result<OpenIDConfigurationScheme>>
55
{
6-
public Task<Result<OpenIDConfigurationScheme>> HandleAsync(
6+
public async Task<Result<OpenIDConfigurationScheme>> HandleAsync(
77
FetchOpenIDConfigurationParameters parameters, CancellationToken cancellation = default)
88
{
9-
var configuration = ConnectMapper.AsConfiguration(host.Address);
9+
var realmFilters = RealmFilters.WithSpecifications()
10+
.WithName(parameters.Realm)
11+
.Build();
1012

11-
return Task.FromResult(Result<OpenIDConfigurationScheme>.Success(configuration));
13+
var realms = await realmCollection.GetRealmsAsync(realmFilters, cancellation);
14+
var realm = realms.FirstOrDefault();
15+
16+
if (realm is null)
17+
{
18+
return Result<OpenIDConfigurationScheme>.Failure(RealmErrors.RealmDoesNotExist);
19+
}
20+
21+
var configuration = ConnectMapper.AsConfiguration(host.Address, parameters.Realm);
22+
23+
return Result<OpenIDConfigurationScheme>.Success(configuration);
1224
}
1325
}

Applications/Backend/Source/HttpsRichardy.Federation.Application/Handlers/Realm/RealmCreationHandler.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
namespace HttpsRichardy.Federation.Application.Handlers.Realm;
22

3-
public sealed class RealmCreationHandler(IRealmCollection collection, IClientCredentialsGenerator credentialsGenerator) :
3+
public sealed class RealmCreationHandler(
4+
IRealmCollection collection,
5+
IClientCredentialsGenerator credentialsGenerator,
6+
ISecretRotationService secretRotationService) :
47
IDispatchHandler<RealmCreationScheme, Result<RealmDetailsScheme>>
58
{
69
public async Task<Result<RealmDetailsScheme>> HandleAsync(
@@ -31,6 +34,7 @@ public async Task<Result<RealmDetailsScheme>> HandleAsync(
3134
.ToList();
3235

3336
await collection.InsertAsync(realm, cancellation: cancellation);
37+
await secretRotationService.EnsureSecretExistsAsync(realm, cancellation);
3438

3539
return Result<RealmDetailsScheme>.Success(RealmMapper.AsResponse(realm));
3640
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
namespace HttpsRichardy.Federation.Application.Handlers.Secret;
2+
3+
public sealed class FetchRealmSecretsHandler(ISecretCollection collection, IRealmCollection realmCollection) :
4+
IDispatchHandler<FetchRealmSecretsParameters, Result<IReadOnlyCollection<SecretScheme>>>
5+
{
6+
public async Task<Result<IReadOnlyCollection<SecretScheme>>> HandleAsync(
7+
FetchRealmSecretsParameters parameters, CancellationToken cancellation = default)
8+
{
9+
var realmFilters = RealmFilters.WithSpecifications()
10+
.WithIdentifier(parameters.RealmId)
11+
.Build();
12+
13+
var realms = await realmCollection.GetRealmsAsync(realmFilters, cancellation);
14+
var realm = realms.FirstOrDefault();
15+
16+
if (realm is null)
17+
{
18+
return Result<IReadOnlyCollection<SecretScheme>>.Failure(RealmErrors.RealmDoesNotExist);
19+
}
20+
21+
var filters = SecretFilters.WithSpecifications()
22+
.WithRealm(parameters.RealmId)
23+
.Build();
24+
25+
var secrets = await collection.GetSecretsAsync(filters, cancellation);
26+
var schemes = secrets
27+
.OrderByDescending(secret => secret.CreatedAt)
28+
.Select(secret => secret.AsResponse())
29+
.ToList()
30+
.AsReadOnly();
31+
32+
return Result<IReadOnlyCollection<SecretScheme>>.Success(schemes);
33+
}
34+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
namespace HttpsRichardy.Federation.Application.Handlers.Secret;
2+
3+
public sealed class RotateRealmSecretsHandler(ISecretRotationService rotationService, IRealmCollection realmCollection) :
4+
IDispatchHandler<RotateRealmSecretsParameters, Result>
5+
{
6+
public async Task<Result> HandleAsync(
7+
RotateRealmSecretsParameters parameters, CancellationToken cancellation = default)
8+
{
9+
var realmFilters = RealmFilters.WithSpecifications()
10+
.WithIdentifier(parameters.RealmId)
11+
.Build();
12+
13+
var realms = await realmCollection.GetRealmsAsync(realmFilters, cancellation);
14+
var realm = realms.FirstOrDefault();
15+
16+
if (realm is null)
17+
{
18+
return Result.Failure(RealmErrors.RealmDoesNotExist);
19+
}
20+
21+
await rotationService.RotateSecretAsync(realm, cancellation);
22+
23+
return Result.Success();
24+
}
25+
}

Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/JsonWebKeysMapper.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ public static JsonWebKeyScheme AsJsonWebKeys(Secret secret)
1515
};
1616
}
1717

18-
public static JsonWebKeySetScheme AsJsonWebKeySetScheme(Secret secret)
18+
public static JsonWebKeySetScheme AsJsonWebKeySetScheme(IReadOnlyCollection<Secret> secrets)
1919
{
2020
return new JsonWebKeySetScheme
2121
{
22-
Keys = [JsonWebKeysMapper.AsJsonWebKeys(secret)]
22+
Keys = [.. secrets.Select(secret => JsonWebKeysMapper.AsJsonWebKeys(secret))]
2323
};
2424
}
2525
}

Applications/Backend/Source/HttpsRichardy.Federation.Application/Mappers/OpenIDMapper.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ namespace HttpsRichardy.Federation.Application.Mappers;
22

33
public static class ConnectMapper
44
{
5-
public static OpenIDConfigurationScheme AsConfiguration(Uri baseUri)
5+
public static OpenIDConfigurationScheme AsConfiguration(Uri baseUri, string realm)
66
{
77
var issuer = baseUri.GetLeftPart(UriPartial.Authority);
88
var authorizeUri = new Uri(baseUri, OpenIDEndpoints.Authorize);
99
var tokenUri = new Uri(baseUri, OpenIDEndpoints.Token);
1010
var userInfoUri = new Uri(baseUri, OpenIDEndpoints.UserInfo);
11-
var jwksUri = new Uri(baseUri, OpenIDEndpoints.Jwks);
11+
var jwksUri = new Uri(baseUri, $"{realm}/{OpenIDEndpoints.Jwks}");
1212

1313
var configuration = new OpenIDConfigurationScheme
1414
{
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace HttpsRichardy.Federation.Application.Mappers;
2+
3+
public static class SecretMapper
4+
{
5+
public static SecretScheme AsResponse(this Secret secret) => new()
6+
{
7+
Id = secret.Id,
8+
CreatedAt = secret.CreatedAt,
9+
ExpiresAt = secret.ExpiresAt,
10+
GracePeriodEndsAt = secret.GracePeriodEndsAt
11+
};
12+
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
namespace HttpsRichardy.Federation.Application.Payloads.Connect;
22

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

33
public sealed record FetchOpenIDConfigurationParameters :
4-
IDispatchable<Result<OpenIDConfigurationScheme>>;
4+
IDispatchable<Result<OpenIDConfigurationScheme>>
5+
{
6+
public string Realm { get; init; } = default!;
7+
}

0 commit comments

Comments
 (0)