Add Web API Calls to Blazor Web App Migrating from .Net6 to 8 Redirects to Login with [Authorize] controllers.

Brett Butcher 0 Reputation points
2024-04-03T11:48:39.5566667+00:00

I have a .Net WASM project that I am migrating to .Net 8 from .Net6. I want to take advantage of some of the advanced render modes.

I used the template in VS2022 with Individual accounts, With 'Auto (Server and WebAssemby) and per page/component as the interactivity location. (I included the sample pages).

I have been struggling so have reduced my app to some simple elements to aid testing. The login etc are all working fine with the template pages. But once 'logged in' I need to get the user details and do other things. Presently via webAPI using WebAssembly (I will move to combined rendering with Interfaces later).

I need to be able to access controllers using both [Authorize] and [AllowAnonymous] controller attributes, but at present I am struggling to get to [Authorize] d controllers with the server side sending back the login page when not logged in (intead 4010 not authroized.

In .net 6 I had to use httpclientfactor on the client to creat two different 'http' objects - one for authorized and onw for not. I haven't got to that yet, but I have seen some documentation that I think will sort it when I do. (But any tips welcome).

This is my backend 'Program.cs:


var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddInteractiveWebAssemblyComponents();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddScoped<IdentityUserAccessor>();
builder.Services.AddScoped<IdentityRedirectManager>();
builder.Services.AddScoped<AuthenticationStateProvider, PersistingRevalidatingAuthenticationStateProvider>();
builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = IdentityConstants.ApplicationScheme;
    options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
})
    .AddIdentityCookies();
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
builder.Services.AddDbContext<RGDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
//builder.Services.AddIdentityCore<ApplicationUser>(options => options.SignIn.RequireConfirmedAccount = true)
//    .AddEntityFrameworkStores<RGDbContext>()
//    .AddSignInManager()
//    .AddDefaultTokenProviders();
builder.Services.AddIdentityCore<ApplicationUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<RGDbContext>()
    .AddSignInManager()
    .AddDefaultTokenProviders();
//builder.Services.AddScoped(sp => new HttpClient
//{
//    BaseAddress = new Uri("https://localhost:7274")
//});
string baseURI = builder.Configuration["applicationUrl"];
if (!string.IsNullOrEmpty(baseURI))
{
    builder.Services.AddScoped(sp => new HttpClient
    {
        BaseAddress = new Uri(baseURI)
    });
    // Add a CORS policy for the client
    // Add .AllowCredentials() for apps that use an Identity Provider for authn/z
    builder.Services.AddCors(
        options => options.AddDefaultPolicy(
            policy => policy.WithOrigins(baseURI,
            baseURI)
                .AllowAnyMethod()
                .AllowAnyHeader()));
}
//builder.Services.AddControllers(); // For web Api
// The options are to stop it enforcing null value checking in json packets to and from the server.
builder.Services.AddControllersWithViews(
    options => options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true
    );
builder.Services.AddAntiforgery(options =>
{
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
/// My additions
///
/// BLAZORISE
builder.Services
    .AddBlazorise(options =>
    {
        options.Immediate = true;
    })
    .AddBootstrap5Providers()
    .AddFontAwesomeIcons();
#region  ****My services and configs****

//AzureHelpers
builder.Services.AddScoped<IAzureHelpers, AzureHelpers>();
//builder.Services.AddSingleton<IEmailSender<ApplicationUser>, IdentityNoOpEmailSender>();
builder.Services.AddTransient<EmailSender>();
// Auto Mapper Configurations
var mappingConfig = new MapperConfiguration(mc =>
{
    mc.AddProfile(new MappingProfile());
    mc.AddCollectionMappers();
});
IMapper mapper = mappingConfig.CreateMapper();
builder.Services.AddSingleton(mapper);
builder.Services.AddScoped<IUserLib, UserLib>();
builder.Services.AddScoped<UserService>();
//PrizeHelpers
builder.Services.AddScoped<IPrizeHelpers, PrizeHelpers>();
//RaffleHelpers
builder.Services.AddScoped<IRaffleGameHelpers, RaffleGameHelpers>();
//SystemHelpers
builder.Services.AddScoped<ISystemHelpers, SystemHelpers>();
//AccountsHelpers
builder.Services.AddScoped<IAccountsHelpers, AccountsHelpers>();
//OrganisationHelpers
builder.Services.AddScoped<IOrgHelpers, OrgHelpers>();
var blobbconnectionString = builder.Configuration.GetConnectionString("DefaultConnection");
var blobconnection = builder.Configuration["ConnectionStrings: RaffleGames_Account:blob"];
var queueconnection = builder.Configuration["ConnectionStrings:RaffleGames_Account:queue"];
builder.Services.AddAzureClients(builder =>
{
    builder.AddBlobServiceClient(blobconnection);
    builder.AddQueueServiceClient(queueconnection);
});
#endregion  ***********************************

////////////APP Section
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseWebAssemblyDebugging();
    app.UseMigrationsEndPoint();
}
else
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}
// Activate the CORS policy
app.UseCors();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode()
    .AddInteractiveWebAssemblyRenderMode()
    .AddAdditionalAssemblies(typeof(RaffleGames.Client._Imports).Assembly);
// Add additional endpoints required by the Identity /Account Razor components.
app.MapAdditionalIdentityEndpoints();
app.MapControllers();
app.Run();


This is the code for the Client program.cs:

var builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddSingleton<AuthenticationStateProvider, PersistentAuthenticationStateProvider>();



builder.Services.AddScoped(sp => new HttpClient
{
    BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});

builder.Services.AddScoped<UserService>();

builder.Services
    .AddBlazorise(options =>
    {
        options.Immediate = true;
    })
    .AddBootstrap5Providers()
    .AddFontAwesomeIcons();

await builder.Build().RunAsync();

I have created 2 simple controllers Test and Values - identical but for the Authorise attibute. This is TestController:

using Microsoft.AspNetCo
    [Route("api/[controller]")]
    [ApiController]
    public class TestController : ControllerBase
    {
        [HttpGet]
        public async Task<ActionResult<string>> Get()
        {
            await Task.Delay(1000);
            return Ok("Yes");
        }
    }
}

... and this is ValuesController:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace RaffleGames.Controllers
{
    [Authorize]
    [Route("api/[controller]")]
    [ApiController]
    public class ValuesController : ControllerBase
    {
        [HttpGet]
        public async Task<ActionResult<string>> Get()
        {
            await Task.Delay(1000);
            return Ok("Yes");
        }
    } 
}



When not logged in, the TestController works fine, but the ValuesController returns a login page as a string. It would seem trying to re-sirect to login. Once logged in, both controllers work correctly, and indeed I have another controller that returns a userDTO that also works fine once logged in.I would like to isolate the 'url/api/*' to make it so it doesn't re-direct but (if possible) retain the ability of the normal pages to re-direct- however that is not a must, as the current app doesnt have that.)

I have trawled the docs, but sadly can't find a scenario like this (which surprises me as I thought it might be quite a common one).

EDIT

The AI assist did help (maybe with the dual http clients - not unlike I have it now on the .net6 version). However, I would still prefer to get an 'unauthorized' result rather than a login string from the apis when not logged in. The situation isn't always avoidable.

Thanks in advance.

ASP.NET Core
ASP.NET Core
A set of technologies in the .NET Framework for building web applications and XML web services.
4,614 questions
Blazor
Blazor
A free and open-source web framework that enables developers to create web apps using C# and HTML being developed by Microsoft.
1,596 questions
ASP.NET API
ASP.NET API
ASP.NET: A set of technologies in the .NET Framework for building web applications and XML web services.API: A software intermediary that allows two applications to interact with each other.
343 questions
0 comments No comments
{count} votes

2 answers

Sort by: Most helpful
  1. Bruce (SqlWork.com) 66,706 Reputation points
    2024-04-03T16:21:49.44+00:00

    the default webapp template uses cookie authentication for the blazor wasm. when the cookie expire, the wasm app redirects to the razor login page. when the wasm host page is rendered it includes a json object in persisted state with user name, and email.

    when the WASM makes a api call, it includes the cookie, but will work with anonymous pages. not sure why you would use two httpclients (the actual interop javascript network call will include the cookie anyway).

    the default template forces a login (redirect to login page), but has no code to detect expired cookie. I believe it counts on the sliding window. You are correct that an api call with an expired cookie will return the login page html rather than error or 401. you can update the cookie code initialization to do this:

    services.Configure<IdentityOptions>(options =>
    {
       options.Cookies.ApplicationCookie.Events = new CookieAuthenticationEvents()
       {
          ...
          OnRedirectToLogin = context =>
          {
             if (context.Request.Path.Value.StartsWith("/api"))
             {
                context.Response.Clear();
                context.Response.StatusCode = 401;
                return Task.FromResult(0);
             }
             context.Response.Redirect(context.RedirectUri);
             return Task.FromResult(0);
          }
       };
    });
    

    of course a better option is to use bearer tokens instead of cookie authentication. your Blazor app would not need to reload to handle login. you would need to change the server application to use token authentication. then add a api login call that returned a JWT token. You would also need to use a custom Authentication State Provider, to update the WASM authentication state after making the login call.


  2. Zhi Lv - MSFT 32,451 Reputation points Microsoft Vendor
    2024-04-05T02:09:38.44+00:00

    Hi Brett Butcher,

    Since you are using Identity Default template and cookie authentication. You can try to use the following code to show the 401 page, instead of redirect to login page.

    builder.Services.AddAuthentication(options =>
    {
        options.DefaultScheme = IdentityConstants.ApplicationScheme;
        options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
        options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme; // Set default challenge scheme to ApplicationScheme
    })
    .AddIdentityCookies(options =>
    {
        options.ApplicationCookie.Configure(opt =>
        {
            opt.Events.OnRedirectToLogin = context =>
            { 
                //directly return 401.
                context.Response.StatusCode = StatusCodes.Status401Unauthorized;
    
                ////Or, you can use the following code.
                ////based on the request path to show 401 error.
                //if (context.Request.Path.Value.StartsWith("/api"))
                //{
                //    context.Response.Clear();
                //    context.Response.StatusCode = 401;
                //    return Task.FromResult(0);
                //}
                ////redirect to login page.
                //context.Response.Redirect(context.RedirectUri);
                return Task.CompletedTask;
            };
        });
    });
    

    If the answer is the right solution, please click "Accept Answer" and kindly upvote it. If you have extra questions about this answer, please click "Comment".

    Note: Please follow the steps in our documentation to enable e-mail notifications if you want to receive the related email notification for this thread.

    Best regards,

    Dillion

    0 comments No comments

Your answer

Answers can be marked as Accepted Answers by the question author, which helps users to know the answer solved the author's problem.