Securing a SPA by authorization server before first load

Using @George's middlware will require authentication on all requests. If you want to run this only for localhost add it under UseSpa wrapped in an env.IsDevelopment() block.

Another option that also works well for deployed environments is to return the index.html from your spa fallback route.

Startup:

        if (!env.IsDevelopment())
        {
            builder.UseMvc(routes =>
            {
                routes.MapSpaFallbackRoute(
                    name: "spa-fallback",
                    defaults: new { controller = "Home", action = "AuthorizedSpaFallBack" });
            });
        }

HomeController:

[Authorize]
public IActionResult AuthorizedSpaFallBack()
{
    var file = _env.ContentRootFileProvider.GetFileInfo("ClientApp/dist/index.html");
    return PhysicalFile(file.PhysicalPath, "text/html");
}

If you need the base.href to match the browser request url (For example a cookie that that has a Path value) you can template that with a regex (or use a razor view like the other examples).

    [Authorize]
    public IActionResult SpaFallback()
    {
        var fileInfo = _env.ContentRootFileProvider.GetFileInfo("ClientApp/dist/index.html");
        using (var reader = new StreamReader(fileInfo.CreateReadStream()))
        {
            var fileContent = reader.ReadToEnd();
            var basePath = !string.IsNullOrWhiteSpace(Url.Content("~")) ? Url.Content("~") + "/" : "/";

            //Note: basePath needs to match request path, because cookie.path is case sensitive
            fileContent = Regex.Replace(fileContent, "<base.*", $"<base href=\"{basePath}\">");
            return Content(fileContent, "text/html");
        }
    }

For local running and using the angular cli dev server you have to require an authenticated user before proxying (or launching the dev server in process):

            app.UseSpa(spa =>
        {
            // To learn more about options for serving an Angular SPA from ASP.NET Core,
            // see https://go.microsoft.com/fwlink/?linkid=864501

            spa.Options.SourcePath = "ClientApp";

            if (env.IsDevelopment())
            {
                app.UseWhen(context => !context.Request.Path.ToString().EndsWith(".map"), appBuilder =>
                {
                    //appBuilder.UseMiddleware<RequireAuthenticationMiddleware>();
                    appBuilder.Run(async (context) =>
                    {
                        if (!context.User.Identity.IsAuthenticated)
                        {
                            await context.ChallengeAsync();
                        }

                    });
                });
                // spa.UseAngularCliServer(npmScript: "start");
                spa.UseProxyToSpaDevelopmentServer("http://localhost:4400");
            }
        });

Based on the Georges Legros I've managed to get this working for .Net Core 3 with Identity Server 4 (the out-of-the-box VS project) so that the app.UseSpa pipeline is not hit if the user is not authenticated via the identity server first. This is much nicer because you don't have to wait for the SPA to load only to then get redirected to the login.

You have to make sure you have authorization/roles working correctly or the User.Identity.IsAuthenticated will always be false.

public void ConfigureServices(IServiceCollection services)
{
    ...

    //Change the following pre-fab lines from

    //services.AddDefaultIdentity<ApplicationUser>()
    //    .AddEntityFrameworkStores<ApplicationDbContext>();

    //To

    services.AddIdentity<ApplicationUser, IdentityRole>()
            .AddRoles<IdentityRole>()
            //You might not need the following two settings
            .AddDefaultUI()
            .AddEntityFrameworkStores<ApplicationDbContext>();

    services.AddIdentityServer()
            .AddApiAuthorization<ApplicationUser, ApplicationDbContext>();

    ...
}

Then add the following the set up the following pipe:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    ...

    app.UseMvc(routes =>
    {
        routes.MapRoute(
            name: "default",
            template: "{controller}/{action=Index}/{id?}");
    });

    //Added this to redirect to Identity Server auth prior to loading SPA    
    app.Use(async (context, next) =>
    {
        if (!context.User.Identity.IsAuthenticated)
        {
            await context.ChallengeAsync("Identity.Application");
        }
        else
        {
            await next();
        }
    });

    app.UseSpa(spa =>
    {
        spa.Options.SourcePath = "ClientApp";

        if (env.IsDevelopment())
        {
            spa.UseAngularCliServer(npmScript: "start");
        }
    });
} 

I have something that seems to work.

In my researches I stumbbled apon this post suggesting to use a middleware instead of the Authorize attribute.

Now, the method used in that post authService does not seem to work in my case (no clue why, I'll continue the investigation and post whaterver I find later on).

So I decided to go with a simpler solution. Here is my config

        app.Use(async (context, next) =>
        {
            if (!context.User.Identity.IsAuthenticated)
            {
                await context.ChallengeAsync("oidc");
            }
            else
            {
                await next();
            }
        });

In this case, oidc kicks in BEFORE the Spa app and the flow is working properly. No need for a Controller at all.

HTH