Skip to content

Commit d1ada64

Browse files
sharpninjaclaude
andcommitted
Switch Duende to CompositeClientStore + WebApplicationFactory tests
Phase D-1 step 2h. Fixes a latent ordering bug and adds a live integration smoke-test harness. The bug: Program.cs was eagerly calling WorkerClientRegistry.Seed() from the top-level before the host builder finalized its configuration sources, so WebApplicationFactory tests that inject in-memory WorkerClients via ConfigureAppConfiguration saw an empty registry. Fixed by registering WorkerClientRegistry as a DI factory singleton that reads IConfiguration at resolution time. The new CompositeClientStore replaces AddInMemoryClients with an IClientStore implementation that queries WorkerClientRegistry live on every FindClientByIdAsync call. This also makes admin secret rotations propagate to the Duende token endpoint immediately — the previous AddInMemoryClients snapshot missed them because the list was captured at service registration time. WorkerClientRegistry.BuildDuendeClient extracted as a public static so CompositeClientStore can produce a single client from a registry entry without going through the ToDuendeClients enumerator. Tests: - New CoordinatorEndpointTests uses WebApplicationFactory<CoordinatorHostMarker> (a marker class added to Program.cs to disambiguate from the Worker project's Program) with an in-memory configuration source that supplies admin credentials, worker client credentials, and a temp SQLite database path. Five xunit cases lock: /health returns ok + phase D-1 /status returns worker+task count shape including the configured WorkerClient the test injected /register without JWT → auth challenge (401/302/403) /work without JWT → auth challenge (401/302/403) /admin/api-keys without cookie → 302 redirect to login - Microsoft.AspNetCore.Mvc.Testing 10.0.4 package reference added to the tests project, gated on net10.0 so the net9 multi-target slice stays lean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 54c0c4d commit d1ada64

5 files changed

Lines changed: 314 additions & 34 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using System.Threading.Tasks;
2+
using BitNetSharp.Distributed.Coordinator.Configuration;
3+
using Duende.IdentityServer.Models;
4+
using Duende.IdentityServer.Stores;
5+
using Microsoft.Extensions.Options;
6+
7+
namespace BitNetSharp.Distributed.Coordinator.Identity;
8+
9+
/// <summary>
10+
/// Duende <see cref="IClientStore"/> that resolves <see cref="Client"/>
11+
/// lookups against two sources:
12+
///
13+
/// <list type="number">
14+
/// <item>
15+
/// The mutable <see cref="WorkerClientRegistry"/> — each entry is
16+
/// rebuilt into a fresh Duende <see cref="Client"/> on every call
17+
/// so admin rotations take effect immediately even for tokens
18+
/// issued via /connect/token.
19+
/// </item>
20+
/// <item>
21+
/// The single static admin UI client built on demand from
22+
/// <see cref="IdentityServerResources.BuildAdminUiClient"/>
23+
/// using the current <see cref="CoordinatorOptions.BaseUrl"/>.
24+
/// </item>
25+
/// </list>
26+
///
27+
/// <para>
28+
/// Using a custom IClientStore is the recommended path in Duende
29+
/// when the client set can change at runtime; the built-in
30+
/// <c>AddInMemoryClients</c> captures its list at registration time
31+
/// and would miss admin-rotate secret bumps.
32+
/// </para>
33+
/// </summary>
34+
public sealed class CompositeClientStore : IClientStore
35+
{
36+
private readonly WorkerClientRegistry _registry;
37+
private readonly IOptionsMonitor<CoordinatorOptions> _options;
38+
39+
public CompositeClientStore(
40+
WorkerClientRegistry registry,
41+
IOptionsMonitor<CoordinatorOptions> options)
42+
{
43+
_registry = registry;
44+
_options = options;
45+
}
46+
47+
public Task<Client?> FindClientByIdAsync(string clientId)
48+
{
49+
if (string.IsNullOrWhiteSpace(clientId))
50+
{
51+
return Task.FromResult<Client?>(null);
52+
}
53+
54+
if (clientId == IdentityServerResources.AdminUiClientId)
55+
{
56+
var coord = _options.CurrentValue;
57+
return Task.FromResult<Client?>(
58+
IdentityServerResources.BuildAdminUiClient(coord.BaseUrl));
59+
}
60+
61+
var entry = _registry.Find(clientId);
62+
if (entry is null)
63+
{
64+
return Task.FromResult<Client?>(null);
65+
}
66+
67+
var lifetime = _options.CurrentValue.AccessTokenLifetimeSeconds;
68+
return Task.FromResult<Client?>(
69+
WorkerClientRegistry.BuildDuendeClient(entry, lifetime));
70+
}
71+
}

src/BitNetSharp.Distributed.Coordinator/Identity/WorkerClientRegistry.cs

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -103,33 +103,46 @@ public string Rotate(string clientId)
103103
/// <see cref="Client"/> model consumed by the in-memory client
104104
/// store. Each worker client is configured for the OAuth 2.0
105105
/// client-credentials grant and the <c>bitnet-worker</c> API scope.
106+
/// Re-evaluated on every call so consumers always see the latest
107+
/// registry state (important for admin-rotate support).
106108
/// </summary>
107109
public IEnumerable<Client> ToDuendeClients(int accessTokenLifetimeSeconds)
108110
{
109111
foreach (var entry in _clients.Values)
110112
{
111-
yield return new Client
112-
{
113-
ClientId = entry.ClientId,
114-
ClientName = entry.DisplayName,
115-
AllowedGrantTypes = GrantTypes.ClientCredentials,
116-
ClientSecrets =
117-
{
118-
new Secret(Sha256Hex(entry.PlainTextSecret))
119-
},
120-
AllowedScopes = { IdentityServerResources.WorkerScopeName },
121-
AccessTokenLifetime = accessTokenLifetimeSeconds,
122-
AccessTokenType = AccessTokenType.Jwt,
123-
AllowOfflineAccess = false,
124-
RequireClientSecret = true,
125-
Claims =
126-
{
127-
new ClientClaim("worker_display_name", entry.DisplayName)
128-
}
129-
};
113+
yield return BuildDuendeClient(entry, accessTokenLifetimeSeconds);
130114
}
131115
}
132116

117+
/// <summary>
118+
/// Builds the Duende <see cref="Client"/> that corresponds to a
119+
/// single registry entry. Exposed as a static so
120+
/// <see cref="CompositeClientStore"/> can reuse the exact same
121+
/// shape when it is asked for a client by id.
122+
/// </summary>
123+
public static Client BuildDuendeClient(WorkerClientEntry entry, int accessTokenLifetimeSeconds)
124+
{
125+
return new Client
126+
{
127+
ClientId = entry.ClientId,
128+
ClientName = entry.DisplayName,
129+
AllowedGrantTypes = GrantTypes.ClientCredentials,
130+
ClientSecrets =
131+
{
132+
new Secret(Sha256Hex(entry.PlainTextSecret))
133+
},
134+
AllowedScopes = { IdentityServerResources.WorkerScopeName },
135+
AccessTokenLifetime = accessTokenLifetimeSeconds,
136+
AccessTokenType = AccessTokenType.Jwt,
137+
AllowOfflineAccess = false,
138+
RequireClientSecret = true,
139+
Claims =
140+
{
141+
new ClientClaim("worker_display_name", entry.DisplayName)
142+
}
143+
};
144+
}
145+
133146
/// <summary>
134147
/// Returns true if the registry currently contains no workers.
135148
/// Used by Program.cs to log a warning at startup when the pool
@@ -159,7 +172,7 @@ private static string GenerateSecret()
159172
/// internal and deliberately simple so we don't take a dep on
160173
/// IdentityModel just for its <c>ToSha256</c> extension method.
161174
/// </summary>
162-
private static string Sha256Hex(string value)
175+
internal static string Sha256Hex(string value)
163176
{
164177
var bytes = Encoding.UTF8.GetBytes(value);
165178
var hash = SHA256.HashData(bytes);

src/BitNetSharp.Distributed.Coordinator/Program.cs

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -91,21 +91,32 @@
9191
});
9292

9393
// ── Worker client registry + Duende IdentityServer ────────────────
94-
var workerRegistry = new WorkerClientRegistry();
95-
workerRegistry.Seed(builder.Configuration
96-
.GetSection($"{CoordinatorOptions.SectionName}:WorkerClients")
97-
.Get<List<WorkerClientOptions>>() ?? new List<WorkerClientOptions>());
98-
builder.Services.AddSingleton(workerRegistry);
94+
// Registry is seeded lazily from IConfiguration so WebApplicationFactory
95+
// integration tests can inject in-memory WorkerClients via
96+
// ConfigureAppConfiguration and have them picked up by the registry.
97+
// The top-level snapshot below is ONLY used for startup-time knobs like
98+
// the JWT authority URL; the real client list lives behind a
99+
// CompositeClientStore that the Duende client lookup consults on every
100+
// request.
101+
builder.Services.AddSingleton<WorkerClientRegistry>(sp =>
102+
{
103+
var configuration = sp.GetRequiredService<IConfiguration>();
104+
var section = configuration.GetSection($"{CoordinatorOptions.SectionName}:WorkerClients");
105+
var clients = section.Get<List<WorkerClientOptions>>() ?? new List<WorkerClientOptions>();
106+
var registry = new WorkerClientRegistry();
107+
registry.Seed(clients);
108+
return registry;
109+
});
99110

100111
var coordinatorSnapshot = builder.Configuration
101112
.GetSection(CoordinatorOptions.SectionName)
102113
.Get<CoordinatorOptions>() ?? new CoordinatorOptions();
103-
var accessTokenLifetimeSeconds = coordinatorSnapshot.AccessTokenLifetimeSeconds;
104114
var coordinatorBaseUrl = coordinatorSnapshot.BaseUrl.TrimEnd('/');
105115

106116
// Duende TestUsers — seeded with the single admin account read from
107-
// CoordinatorOptions.Admin. The cookie-based IS login flow validates
108-
// credentials against this list via the default ResourceOwnerPasswordValidator.
117+
// CoordinatorOptions.Admin. If the admin credentials are empty at
118+
// startup, an empty list goes in and the login page cannot succeed;
119+
// operators see a log warning on first /admin/api-keys hit.
109120
var adminTestUsers = new List<TestUser>();
110121
if (!string.IsNullOrWhiteSpace(coordinatorSnapshot.Admin.Username) &&
111122
!string.IsNullOrWhiteSpace(coordinatorSnapshot.Admin.Password))
@@ -123,11 +134,6 @@
123134
});
124135
}
125136

126-
// Merge worker clients + admin UI client into the Duende client list.
127-
var duendeClients = new List<Client>();
128-
duendeClients.AddRange(workerRegistry.ToDuendeClients(accessTokenLifetimeSeconds));
129-
duendeClients.Add(IdentityServerResources.BuildAdminUiClient(coordinatorBaseUrl));
130-
131137
builder.Services.AddIdentityServer(options =>
132138
{
133139
options.Events.RaiseErrorEvents = true;
@@ -144,7 +150,7 @@
144150
.AddInMemoryIdentityResources(IdentityServerResources.IdentityResources)
145151
.AddInMemoryApiScopes(IdentityServerResources.ApiScopes)
146152
.AddInMemoryApiResources(IdentityServerResources.ApiResources)
147-
.AddInMemoryClients(duendeClients)
153+
.AddClientStore<CompositeClientStore>()
148154
.AddTestUsers(adminTestUsers)
149155
.AddDeveloperSigningCredential(persistKey: false);
150156

@@ -552,3 +558,16 @@ static string BuildConnectionString(CoordinatorOptions coord) =>
552558
/// discriminate the assembly entry point.
553559
/// </summary>
554560
public partial class Program;
561+
562+
namespace BitNetSharp.Distributed.Coordinator
563+
{
564+
/// <summary>
565+
/// Named marker class that exists only so the tests project can
566+
/// say <c>WebApplicationFactory&lt;CoordinatorHostMarker&gt;</c>
567+
/// without colliding with the Worker project's top-level
568+
/// <see cref="Program"/> class. WebApplicationFactory uses the
569+
/// type parameter only to locate the assembly to bootstrap; any
570+
/// type in the coordinator assembly works.
571+
/// </summary>
572+
public sealed class CoordinatorHostMarker;
573+
}

tests/BitNetSharp.Tests/BitNetSharp.Tests.csproj

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@
1919
<PackageReference Include="xunit" Version="2.9.3" />
2020
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
2121
</ItemGroup>
22+
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
23+
<!--
24+
WebApplicationFactory is only needed for the coordinator
25+
integration tests which run exclusively on the net10 slice.
26+
Pinning this to the net10 conditional keeps the net9 compile
27+
of the test project free of ASP.NET Core 10 transitive deps.
28+
-->
29+
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.4" />
30+
</ItemGroup>
2231

2332
<ItemGroup>
2433
<Using Include="Xunit" />

0 commit comments

Comments
 (0)