microsoft / microsoft/aspire

`AzureComponent.CreateHealthCheck` does not resolve `TokenCredential` from DI, breaking health checks when FQDN auth is used without an explicit `configureSettings` callback

Open
#17,442 1 comment 0 reactions 0 assignees View on GitHub
area-integrations triage:bot-seen
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.