`AzureComponent.CreateHealthCheck` does not resolve `TokenCredential` from DI, breaking health checks when FQDN auth is used without an explicit `configureSettings` callback
- Dominant language
- C#
- Stars
- 6.3k
- Forks
- 991
- Avg merge
- 2d 15h
- Merged PRs (30d)
- 196
Description
### Is there an existing issue for this?
- [x] I have searched the existing issues
### Describe the bug
# Summary
For every `Aspire.Azure.*` integration built on the shared `AzureComponent` base, the auto-registered health check ignores DI when resolving the `TokenCredential`. It reads `settings.Credential` directly. Because `TokenCredential` is not bindable from JSON, this is `null` unless the consumer explicitly provides a `configureSettings` callback that sets it.
Result: the underlying client (e.g. `ServiceBusClient`) is built correctly via `Microsoft.Extensions.Azure`'s factory and works, but the health check throws `ArgumentNullException: options.Credential` on the very first probe and the readiness endpoint returns 500.
This is a more focused diagnosis of the symptom previously reported in #8693 (which is still open in Backlog with no root-cause comment), and it applies to more components than just Service Bus.
# Expected behavior
If `AddAzureServiceBusClient(name)` alone is enough to produce a working `ServiceBusClient`, it should also be enough to produce a working health check. The same is applicable for all the rest of Azure Components that use a HealthCheck.
# Repro (Service Bus)
`Program.cs`:
```csharp
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHealthChecks();
builder.AddAzureServiceBusClient("sb");
// no configureSettings, no connection string — only FQDN + queue health check
```
`appsettings.json`:
```json
{
"Aspire": {
"Azure": {
"Messaging": {
"ServiceBus": {
"sb": {
"FullyQualifiedNamespace": ".servicebus.windows.net",
"HealthCheckQueueName": ""
}
}
}
}
}
}
```
Hit `/health`. You get:
```
System.ArgumentNullException: Value cannot be null. (Parameter 'options.Credential')
at HealthChecks.AzureServiceBus.AzureServiceBusHealthCheck`1..ctor(...)
at HealthChecks.AzureServiceBus.AzureServiceBusQueueHealthCheck..ctor(...)
at Microsoft.Extensions.Hosting.AspireServiceBusExtensions.MessageBusComponent
.CreateHealthCheck(ServiceBusClient client, AzureMessagingServiceBusSettings settings)
at Aspire.Azure.Common.AzureComponent`3.b__12_5(IServiceProvider sp)
```
Meanwhile `serviceProvider.GetRequiredService()` produced a fully functional client, because the client factory uses a credential resolved through `Microsoft.Extensions.Azure`.
# Workaround
```csharp
builder.AddAzureServiceBusClient(
"sb",
configureSettings: s => s.Credential = new DefaultAzureCredential());
```
This is the *only* way to populate `settings.Credential` today — JSON binding can't materialize a `TokenCredential`, and `IOptions` post-configuration doesn't help because the settings instance is captured at registration time.
# Root cause
In `src/Components/Common/AzureComponent.cs`, the client registration uses Microsoft.Extensions.Azure's resolution pipeline:
```csharp
builder.Services.AddAzureClients(azureFactoryBuilder =>
{
var clientBuilder = AddClient(azureFactoryBuilder, settings, connectionName, configurationSectionName);
if (GetTokenCredential(settings) is { } credential)
clientBuilder.WithCredential(credential);
...
});
```
and the `ServiceBusClient` factory accepts a `cred` parameter from that pipeline (`requiresCredential: false`), so it transparently falls back to whatever credential the factory has — typically `DefaultAzureCredential`.
But the health check registration is a hand-built factory that captures `settings` directly:
```csharp
builder.TryAddHealthCheck(new HealthCheckRegistration(
...,
serviceProvider =>
{
var client = ... GetRequiredService() ...;
return CreateHealthCheck(client, settings); // <-- 'settings' captured
},
...));
```
`CreateHealthCheck` then passes `settings.Credential` straight into the health check options:
```csharp
protected override IHealthCheck CreateHealthCheck(ServiceBusClient client, AzureMessagingServiceBusSettings settings)
=> new AzureServiceBusQueueHealthCheck(new AzureServiceBusQueueHealthCheckOptions(settings.HealthCheckQueueName)
{
FullyQualifiedNamespace = settings.FullyQualifiedNamespace,
ConnectionString = settings.ConnectionString,
Credential = settings.Credential, // <-- null when not set by configureSettings
});
```
There is no fallback to a DI-resolved `TokenCredential`, and there is no fallback to a default `DefaultAzureCredential` the way the client factory has.
# Scope — not just Service Bus
The same pattern lives in every `AzureComponent`-based integration. At least the following overrides pass `settings.Credential` directly into a health check options object:
- `Aspire.Azure.Messaging.ServiceBus` (`AzureServiceBusQueueHealthCheck` / `AzureServiceBusTopicHealthCheck`)
- `Aspire.Azure.Messaging.EventHubs` (`AzureEventHubHealthCheck`)
- `Aspire.Azure.Storage.Blobs` (`AzureBlobStorageHealthCheck`)
- `Aspire.Azure.Storage.Queues` (`AzureQueueStorageHealthCheck`)
- `Aspire.Azure.Storage.Files.DataLake` / Shares
- `Aspire.Azure.Security.KeyVault` (`AzureKeyVaultSecretsHealthCheck`)
- `Aspire.Azure.Data.Tables` (`AzureTableServiceHealthCheck`)
All of them will hit the same `ArgumentNullException` if a user enables the health check with FQDN + DI-provided token auth and doesn't supply `configureSettings`. Most consumers don't notice today because the dev emulator / quickstarts use connection strings, which take precedence and avoid the credential path.
# Proposed fix
Change the health check registration in `AzureComponent.AddClient` to thread an `IServiceProvider` through to `CreateHealthCheck`, and update `CreateHealthCheck` overrides to fall back in this order:
1. `settings.Credential`
2. `serviceProvider.GetService()`
3. `new DefaultAzureCredential()` — matching the client factory's effective default
Minimal sketch:
```csharp
builder.TryAddHealthCheck(new HealthCheckRegistration(
...,
serviceProvider =>
{
var client = ...;
var credential = settings.Credential
?? serviceProvider.GetService()
?? new DefaultAzureCredential();
return CreateHealthCheck(client, settings, credential);
},
...));
```
and change every `CreateHealthCheck` override to use the passed-in credential instead of `settings.Credential`.
This restores the principle of least surprise: if `AddAzureServiceBusClient(name)` alone is enough to produce a working `ServiceBusClient`, it should also be enough to produce a working health check.
# Related
- #8693 — same symptom, no root-cause analysis, open in Backlog. This issue is intended to supersede / sharpen it.
### Expected Behavior
_No response_
### Steps To Reproduce
_No response_
### Exceptions (if any)
_No response_
### Aspire doctor output
_No response_
### Anything else?
_No response_
Contributor guide
Assessment
This issue has not been assessed yet.