Search code examples
c#asp.net-coreazure-ad-msalmicrosoft.identity.web

Inject ITokenAcquisition in Singleton Service does not work with ASPNETCORE_ENVIRONMENT=Development


I noticed something strange when injecting ITokenAcquisition.
You can find my code to reproduce the issue here: Github Repo
I use the NuGet Microsoft.Identity.Web 2.18.0 to enable MSAL authentication in my ASP.NET Api (.NET 7)

My Program.cs looks like this:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration)
    .EnableTokenAcquisitionToCallDownstreamApi()
    .AddInMemoryTokenCaches();

// Add services to the container.

builder.Services.AddControllers();
builder.Services.AddHostedService<QueuedHostedService>();
var app = builder.Build();

// Configure the HTTP request pipeline.

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

You can see that together with my API, a background service is also running:

public class QueuedHostedService : BackgroundService
{
    ITokenAcquisition _tokenAcquisition;
    public QueuedHostedService(ITokenAcquisition tokenAcquisition)
    {
        _tokenAcquisition = tokenAcquisition;
    }


    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await BackgroundProcessing(stoppingToken);
    }

    private async Task BackgroundProcessing(CancellationToken stoppingToken)
    {
        var token = await _tokenAcquisition.GetAccessTokenForAppAsync("api://xxxxx-5a7f-430e-8ea1-6e133055990e/.default");
        while (!stoppingToken.IsCancellationRequested)
        {
            Console.WriteLine("Executing...");
        }
    }

    public override async Task StopAsync(CancellationToken stoppingToken)
    {
        // "Queued Hosted Service is stopping.");

        await base.StopAsync(stoppingToken);
    }
}

This service uses ITokenAcquisition to get an access token for another API (could be the graph or so)

My controller action looks almost standard, but has the Authorize-Attribute

[Authorize]
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    ITokenAcquisition _tokenAcquisition;
    public WeatherForecastController(ITokenAcquisition tokenAcquisition) 
    { 
        _tokenAcquisition = tokenAcquisition;
    }

    private static readonly string[] Summaries = new[]
    {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

    [HttpGet]
    public async Task<IEnumerable<WeatherForecast>> GetAsync()
    {
        var token = await _tokenAcquisition.GetAccessTokenForAppAsync("api://xxxxxx-5a7f-430e-8ea1-6e133055990e/.default");
        return Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = Summaries[Random.Shared.Next(Summaries.Length)]
        })
        .ToArray();
    }
}

My code works and the background service receives the access token, but only when the ASPNETCORE_ENVIRONMENT variable is set to "Test" or "Production".

In Development my service fails with the following error message:

Cannot consume scoped service 'Microsoft.Identity.Web.ITokenAcquisition' from singleton 'Microsoft.Extensions.Hosting.IHostedService'.)'

Anybody knows:

  • Is it allowed to inject ITokenAcquisition in a background service?
  • How can it be that it works in Test or Production modes, but never in Development mode?

Solution

  • How can it be that it works in Test or Production modes, but never in Development mode?

    This is the core of your issue. When running in "Development mode" (when that environment variable is set to Development) the Microsoft.Extensions.DependencyInjection container will perform what they call scope validation, meaning it will attempt to see if your container configuration violates scope isolation: if the lifetime of a dependency is shorter than the lifetime of the containing class.

    This is what is happening in your case: you are trying to inject a service that appears to be registered as a scoped dependency in DI, into a singleton service (hosted services are singletons by default).

    This validation is there precisely to guide you away from this pattern which can create really hard to debug problems. The issue is also sometimes referred to as "captive dependency".

    Is it allowed to inject ITokenAcquisition in a background service?

    Hosted services support full dependency injection, but as per the above, you do need to be careful about what exactly you are injecting, and what the lifetime of that object is.

    If you have a background service that performs many tasks, you need to scope the resolution of dependencies to those tasks, and not to the background service itself.

    Usually, it is a good idea to decouple the hosted service from the task processor. Then, you can do something like this:

    public class QueuedHostedService : BackgroundService
    {
        private readonly IServiceScopeFactory _scopeFactory;
        
        public QueuedHostedService(IServiceScopeFactory scopeFactory)
        {
            _scopeFactory = scopeFactory;
        }
    
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            await BackgroundProcessing(stoppingToken);
        }
    
        private async Task BackgroundProcessing(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                // Get item from your queue...
                var queueItem = ...
                
                await using var scope = _scopeFactory.CreateAsyncScope();
                var processor = scope.ServiceProvider.GetRequiredService<IQueueItemProcessor>();
                
                await processor.ProcessAsync(queueItem); 
            }
        }
    }
    

    Then, you create a separate class that performs the item processing:

    public class QueueItemProcessor : IQueueItemProcessor
    {
        private readonly ITokenAcquisition _tokenAcquisition;
    
        public QueueItemProcessor(ITokenAcquisition tokenAcquisition)
        {
            _tokenAcquisition = tokenAcquisition;
        }
    
        public async Task ProcessAsync(QueueItem queueItem)
        {
            Console.WriteLine("Executing...");
            var token = await _tokenAcquisition.GetAccessTokenForAppAsync("api://xxxxx-5a7f-430e-8ea1-6e133055990e/.default");
            
            // perform call with token here...
        }
    }
    

    Now, since your item processor is resolved on a per item basis, it will get it's own scoped instance of the ITokenAcquisition implementation and you should not see the error in development mode anymore.