Francesco Cristallo
Francesco Cristallo

Reputation: 3073

In Blazor .Net 8 how to use Cookie Authentication in HttpClient for the Server part of Interactive Auto component calling a WebApi on the same project

I created a .Net 8 Blazor project with

I added Nuget Package to .Client project

Microsoft.Extensions.Http

Added to both .Server and .Client Program.cs

builder.Services.AddHttpClient();

And the API to .Server Program.cs

app.MapGet("/hello", () =>
{
   return "hello";
})
.RequireAuthorization();

I then updated Auth.razor page with the call to the Web API. This code is executed without Prerendering, and at first using Blazor Server, then Webasssembly from the second reload on.

@page "/auth"

@using Microsoft.AspNetCore.Authorization

@attribute [Authorize]
@rendermode @(new InteractiveAutoRenderMode(prerender: false))
@inject HttpClient Client

<PageTitle>Auth</PageTitle>

<h1>You are authenticated</h1>

<AuthorizeView>
    Hello @context.User.Identity?.Name!
</AuthorizeView>

@Message

@code
{
    private string Message;
    protected override async Task OnInitializedAsync()
    {
        try
        {
            Message = await Client.GetStringAsync("https://localhost:7235/hello");
        }
        catch (Exception e)
        {
            Console.WriteLine(e.Message);   
        }
    }
}

When launching the app, I register/login using the new out of the box identity system, and get a cookie.

The problem is that when HttpClient calls the API, only using Webassembly it authenticates using the cookie in the browser; when the same code is called first using Blazor Server, the API call is not authenticated, and the HTML of an error page is returned.

Workflow:

  1. I login, then go to Auth page
  2. First load using Blazor Server: HttpClient call is Not authenticated
  3. All other loads using Webassembly: httpClient uses cookie, and is Authenticated, "hello" is returned

If I switch to "InteractiveWebassembly" it works. If I switch to "InteractiveServer" it does not work. If I call via browser the API directly, it works.

What I want is to use a single codebase for both. Is there a way to make HttpClient use the cookie authentication token when used the first time by Blazor Server?

Or is necessary to create a diversification and call just the C# backend code when it's Blazor-Server?

Upvotes: 1

Views: 6087

Answers (3)

MIP
MIP

Reputation: 6594

Edit: Update 14 Oct 24:

Here is a sample from Microsoft showing patterns you can use for http client auth that works on both back end and client side component rendering:

https://github.com/dotnet/blazor-samples/tree/main/9.0/BlazorWebAppOidcBff

Original answer

I'm hitting this issue myself, It's an interesting approach in the answer from Carlin, but I'm realizing there may be some issue's. On the server side, it's going to go direct to the service without going through the controller and through any authorization attributes. So if you want [Authorize(Role = "Admin")] on one of your controller actions, that's not going to take effect on a server side render. You're then dependent on logic in your razor pages hiding unauthorized actions so you might end up with some inconstancy rather than your API being your single security gateway.

Also, when you're registering a service with DI that uses HttpClient, I think it's best practice with .NET 8 to use:

builder.Services.AddHttpClient<ITestService, TestService>( ...

So .NET can use IHttpClientFactory and manage the client/connection lifetime.

edit, Updated answer with what I ended up doing:

The approach outlined by Carlin, while it may work, isn't that safe. You'll potentially get inconsistent security behaviour in your app when it's server rendering vs web assembly.

I'd recommend treating your API as a consistent gateway to your data from your application. If you implement role based auth attributes or polices on your API, if you swap in an implementation server side that doesn't go via your controller, you're bypassing your own security.

To keep things consistent, I would use one set of HttpClient classes for your blazor components to use and configure your API to use JWT authentication. Then your Blazor components have one conistant way of talking to your data, via your API.

In my case, I create my http client classes in the web.client project e.g.

public class MyClient(HttpClient httpClient, ILogger<MyClient> logger) : IMyClient
{
    ....
}

And then register those clients for DI in both the Web and Web.Client blazor projects e.g.

builder.Services.AddHttpClient<IMyClient, MyClient>(client =>
    client.BaseAddress = new Uri(apiBaseUrl!))
    .AddUserAccessTokenHandler()

Both client and server, you'll need to plug in token handling which will vary depending on your identity back end, so you'll need to probe documentation for that. In my case I've a different token handler implementation for web and client, but both result in an http client that acquires and manages JWT tokens from our oidc server.

Finally on your API, you'll need to accept JWT token auth e.g.:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
    options.Authority = builder.Configuration["Identity:Authority"];
    options.RequireHttpsMetadata = false;
    options.Audience = "api";
    options.TokenValidationParameters = new TokenValidationParameters
    {
        NameClaimType = "name"
    };
});

If your API and web project are one and the same, you can add this without setting it as the default as above, and specify the authentication scheme on the authorize attribute on your controllers.

Upvotes: 0

Filip
Filip

Reputation: 61

I had exactly the same issue and ended up solving it by using a DelegateHandler on the HttpClient which gets the Identity Cookie from the HttpContextAccessor and then just adds it to the request header.

public class IdentityCookieHandler : DelegatingHandler
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public IdentityCookieHandler(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var httpContext = _httpContextAccessor?.HttpContext;
        if (httpContext != null)
        {            
            var authenticationCookie = httpContext.Request.Cookies[".AspNetCore.Identity.Application"];
            if (!string.IsNullOrEmpty(authenticationCookie))
            {                
                request.Headers.Add("Cookie", new CookieHeaderValue(".AspNetCore.Identity.Application", authenticationCookie).ToString());
            }
        }

        return await base.SendAsync(request, cancellationToken);
    }
}

Then in Program.cs I just register it as follows:

builder.Services.AddTransient<IdentityCookieHandler>();
builder.Services.AddHttpClient("serverApi", options =>
{
    options.BaseAddress = new Uri(builder.Configuration.GetValue<string>("BaseApi")!);    
    options.DefaultRequestHeaders.Add("Accept", "application/json");

}).AddHttpMessageHandler<IdentityCookieHandler>();

Not sure if it is the best of solutions, but it's what I came up with.

Upvotes: 2

Carlin Archer
Carlin Archer

Reputation: 9

I was having this exact same issue.

I solved it by using the built in dependency injection.

I first created a shared interface that can be used by both the Web project (the server side project) and the Web.Client project (the WASM project). I put this in a new project that is shared by both the Web and Web.Client projects.

e.g.:

public interface ITestService
{
    Task<List<TestDTO>> GetAll();
    public Task<TestDTO?> GetAsync(int? ID);
    Task SaveAsync(TestDTO test);
}

Then I created two implementations for this interface, once using Entity Framework (to be used by the Web project only) and another using APIs to get the data (used by the Web.Client project only).

This is the EF implementation:

 /// <summary>
 /// EF Test service that fetches data from the database using Entity Framework.
 /// </summary>
 public class TestService : ITestService
 {
     private readonly ITestRepo testRepo;

     public TestService(ITestRepo testRepo)
     {
         if (testRepo == null)
         {
             throw new ArgumentNullException(nameof(testRepo));
         }
         this.testRepo = testRepo;
     }

     public async Task<TestDTO?> GetAsync(int? ID)
     {
         var test = await testRepo.GetAll().Where(i => i.ID == ID).FirstOrDefaultAsync();
         return test?.ToDTO();
     }

     public async Task SaveAsync(TestDTO testDTO)
     {
         var test = await testRepo.GetAll().Where(i => i.ID == testDTO.ID).FirstOrDefaultAsync() ?? new Test() { Name = testDTO.Name, Description = testDTO.Description };
         if (test.ID == 0)
         {
             test.DateCreated = DateTime.Now;
         }
         else
         {
             test.DateModified = DateTime.Now;
         }
         await testRepo.SaveAsync(test.ID, test);
     }

     public async Task<List<TestDTO>> GetAll()
     {
         return await testRepo.GetAll().Select(i => i.ToDTO()).ToListAsync();
     }
 }

This is the API implementation:

/// <summary>
/// API test service that fetches data from the API.
/// </summary>
public class TestService : ITestService
{
    private readonly HttpClient httpClient;

    public TestService(HttpClient httpClient)
    {
        this.httpClient = httpClient;
    }
    public async Task<TestDTO?> GetAsync(int? ID)
    {
        return await httpClient.GetFromJsonAsync<TestDTO>($"/test/{ID}");
    }

    public async Task SaveAsync(TestDTO test)
    {
        await httpClient.PostAsJsonAsync<TestDTO>("/test", test);

    }

    public async Task<List<TestDTO>> GetAll()
    {
        return await httpClient.GetFromJsonAsync<List<TestDTO>>("/test"); ;
    }
}

I made sure to only add a project reference to the correct implementation for each.

Now in the Web and Web.Client project when you inject the ITestService it will use the version it needs - the API version for the Web.Client and the EF version for the main Web project - so you add this to each Program.cs file:

builder.Services.AddScoped<ITestService, TestService>();

Once you've done that, you can use the service on the page and if it's using WASM it will use the API and if it's not WASM it will use the EF service.

Now both will work with the Authorization.

Upvotes: 0

Related Questions