Add SpiffeHttpHandler with auto-rotating mTLS certificate support#439
Add SpiffeHttpHandler with auto-rotating mTLS certificate support#439
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces an auto-rotating HttpMessageHandler for SPIFFE mTLS clients and adds update notifications to Workload API sources so consumers can react to SVID/bundle rotation.
Changes:
- Added
SpiffeHttpHandlerthat rebuilds/swaps its innerSocketsHttpHandlerwhen the backingX509Sourceupdates. - Added
Updatedevents toSourceandBundleSource, and switched relatedTaskCompletionSourceinstances toRunContinuationsAsynchronously. - Updated README and sample clients to use
SpiffeHttpHandlerinstead of manually wiringSocketsHttpHandler.SslOptions.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Spiffe.Tests/Ssl/TestSpiffeHttpHandler.cs | Adds unit tests for Updated and SpiffeHttpHandler behaviors. |
| src/Spiffe/WorkloadApi/Source.cs | Adds Updated event and makes TCS continuations async. |
| src/Spiffe/WorkloadApi/BundleSource.cs | Adds Updated event, makes TCS continuations async, raises Updated on bundle updates. |
| src/Spiffe/Ssl/SpiffeHttpHandler.cs | New rotating handler that swaps inner HTTP handler on X509Source updates. |
| samples/local/Spiffe.Sample.Grpc.Mtls/Client/Program.cs | Switches gRPC client to SpiffeHttpHandler. |
| samples/docker/Spiffe.Sample.Grpc.Mtls/Client/Program.cs | Switches gRPC client to SpiffeHttpHandler. |
| samples/docker/Spiffe.Sample.AspNetCore.Mtls/Client/Program.cs | Switches HttpClient creation to SpiffeHttpHandler. |
| README.md | Updates mTLS dialing snippet and documents handler rotation behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| _initializedX509.TrySetResult(true); | ||
| Updated?.Invoke(); | ||
| } |
There was a problem hiding this comment.
Updated?.Invoke() is executed inline as part of the update path. A throwing subscriber will propagate back through Watcher.OnUpdate, which can be caught as a watch error and trigger reconnect/backoff, disrupting updates. Consider shielding the update path from subscriber exceptions (catch/log per subscriber or similar).
There was a problem hiding this comment.
Left as-is intentionally. SpiffeHttpHandler.Refresh() (the only subscriber) does not throw, and adding a silent catch or per-subscriber iteration would hide bugs in future subscribers without a logger to surface them. If a subscriber throws it is a bug in that subscriber — propagating the exception makes it visible rather than swallowing it silently.
| } | ||
|
|
||
| _initializedJwt.TrySetResult(true); | ||
| Updated?.Invoke(); | ||
| } |
There was a problem hiding this comment.
Same as above: invoking Updated inline means a subscriber exception can break the watch update flow (and trigger reconnect/backoff). It’s safer if SetJwtBundles can’t be taken down by consumer event handlers.
There was a problem hiding this comment.
Same reasoning as above — left as-is intentionally.
| public SpiffeHttpHandler(X509Source source, IAuthorizer authorizer, TimeSpan? drainDelay = null) | ||
| { | ||
| _source = source ?? throw new ArgumentNullException(nameof(source)); | ||
| _authorizer = authorizer ?? throw new ArgumentNullException(nameof(authorizer)); | ||
| _drainDelay = drainDelay ?? TimeSpan.FromSeconds(30); | ||
| _inner = CreateInvoker(); | ||
| _source.Updated += Refresh; | ||
| } |
There was a problem hiding this comment.
drainDelay is accepted as-is; negative values (other than Timeout.InfiniteTimeSpan) will cause Task.Delay(_drainDelay) to throw during Refresh(), potentially on a Workload API update callback thread. Consider validating/clamping drainDelay in the constructor and throwing an ArgumentOutOfRangeException early.
There was a problem hiding this comment.
Fixed in 5b9ef5c. Constructor now validates drainDelay is in [0, 10 minutes] and throws ArgumentOutOfRangeException eagerly. 10 minutes chosen as the upper bound based on common web server and proxy drain/timeout defaults.
| private void Refresh() | ||
| { | ||
| if (_disposed) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| HttpMessageInvoker old = Interlocked.Exchange(ref _inner, CreateInvoker()); | ||
| _ = Task.Delay(_drainDelay).ContinueWith(_ => old.Dispose(), TaskScheduler.Default); | ||
| } |
There was a problem hiding this comment.
Refresh() can race with Dispose(): it checks _disposed and then creates/swaps _inner. If disposal happens after the check but before/while exchanging, Refresh() can install a new HttpMessageInvoker after disposal, leaking sockets/handlers because the instance is already disposed and will never dispose the newly created invoker. Consider synchronizing Dispose/Refresh (e.g., a lock) or using an interlocked disposed state plus a double-check that disposes the newly created invoker if disposal won the race.
There was a problem hiding this comment.
Fixed in 5b9ef5c. Added _lock to synchronize Refresh() and Dispose(). CreateInvoker() runs outside the lock (it is the expensive part); the lock only guards the install-or-discard decision. If Dispose() wins the lock after Refresh() already created a new invoker, that invoker is disposed immediately inside the lock before returning.
| [Fact] | ||
| public void TestHandlerRefreshesOnSourceUpdate() | ||
| { | ||
| using X509Source source = MakeInitializedSource(); | ||
| int refreshCount = 0; | ||
|
|
||
| // Intercept Updated to count refreshes triggered by SpiffeHttpHandler's subscription | ||
| // We add our own listener alongside SpiffeHttpHandler's internal listener. | ||
| using SpiffeHttpHandler handler = new(source, Authorizers.AuthorizeAny()); | ||
| source.Updated += () => refreshCount++; | ||
|
|
||
| // Trigger two more updates | ||
| source.SetX509Context(MakeContext()); | ||
| source.SetX509Context(MakeContext()); | ||
|
|
||
| // Our counter saw 2 updates; SpiffeHttpHandler saw the same and refreshed its inner handler | ||
| refreshCount.Should().Be(2); | ||
| } |
There was a problem hiding this comment.
TestHandlerRefreshesOnSourceUpdate only asserts that X509Source.Updated fired; it doesn’t verify that SpiffeHttpHandler actually swapped/rotated its inner handler (the behavior this test name implies). To make this test meaningful, consider adding an observable seam (e.g., internal accessor/injected factory/callback) so the test can assert that the inner handler/invoker instance changes after an update.
There was a problem hiding this comment.
Fixed in 5b9ef5c. Added internal CurrentInvoker accessor and the test now asserts the actual invoker instance changes after each update.
| [Fact] | ||
| public void TestDisposeUnsubscribesFromUpdated() | ||
| { | ||
| using X509Source source = MakeInitializedSource(); | ||
| SpiffeHttpHandler handler = new(source, Authorizers.AuthorizeAny()); | ||
| handler.Dispose(); | ||
|
|
||
| // After disposal, firing Updated must not throw (handler already unsubscribed) | ||
| Action trigger = () => source.SetX509Context(MakeContext()); | ||
| trigger.Should().NotThrow(); | ||
| } |
There was a problem hiding this comment.
TestDisposeUnsubscribesFromUpdated currently only asserts that triggering an update doesn’t throw after disposal. Since Refresh() is already resilient and would likely not throw even if still subscribed, this doesn’t actually validate that the handler unsubscribed. Consider asserting unsubscription explicitly (e.g., via an observable seam/instrumentation that records whether Refresh ran after disposal).
There was a problem hiding this comment.
Fixed in 5b9ef5c. Test now captures CurrentInvoker before disposal and asserts it is unchanged after firing an update, proving Refresh() was not called.
| protected virtual void Initialized() | ||
| { | ||
| _initialized.TrySetResult(true); | ||
| Updated?.Invoke(); | ||
| } |
There was a problem hiding this comment.
Updated?.Invoke() runs subscriber code inline. If any subscriber throws, that exception will bubble out of Initialized() into the Workload API watch loop (via Watcher.OnUpdate), potentially causing reconnect/backoff loops and disrupting future updates. Consider invoking subscribers defensively (e.g., iterate invocation list and catch/log per handler, or otherwise ensure subscriber exceptions don’t break source update processing).
There was a problem hiding this comment.
Left as-is intentionally. SpiffeHttpHandler.Refresh() (the only subscriber) does not throw, and adding a silent catch or per-subscriber iteration would hide bugs in future subscribers without a logger to surface them. If a subscriber throws it is a bug in that subscriber — propagating the exception makes it visible rather than swallowing it silently.
…mprove tests - Validate drainDelay in [0, 10 minutes] range in constructor - Add _lock to synchronize Refresh() and Dispose(), preventing a race where a newly created HttpMessageInvoker could be leaked if disposal won after the _disposed check in Refresh() - Add internal CurrentInvoker accessor for testing - TestHandlerRefreshesOnSourceUpdate: assert inner invoker instance changes - TestDisposeUnsubscribesFromUpdated: assert inner invoker unchanged after disposal - Add TestInvalidDrainDelayThrows Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… published package yet) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
SpiffeHttpHandler: anHttpMessageHandlerthat presents the current X.509 SVID for mTLS and automatically swaps its innerSocketsHttpHandlerwhenever theX509Sourcereceives a rotated certificateUpdatedevent toSource(base class) andBundleSourceso consumers can react to credential rotationTaskCompletionSourceconstruction to useTaskCreationOptions.RunContinuationsAsynchronouslyacrossSourceandBundleSourceSpiffeHttpHandlerinstead of manualSocketsHttpHandler+SslOptionswiringSpiffeHttpHandlercovering rotation, disposal, and null-argument guardsTest plan
make build)make test)SpiffeHttpHandler🤖 Generated with Claude Code