Securing a SPA by authorization server before first load

11,436

Solution 1

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

Solution 2

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");
            }
        });

Solution 3

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");
        }
    });
} 

Solution 4

Make this change to your startup.cs:

app.UseSpa(spa =>
{
    spa.Options.SourcePath = "ClientApp";
    spa.Options.DefaultPage = "/home/index";

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

Then put the reference to the angular app in the index.cshtml:

<app-root></app-root>

and make sure you include all of the needed files in the index.cshtml file or your layout:

<link href="~/styles.bundle.css" rel="stylesheet" />

<script type="text/javascript" src="~/inline.bundle.js" asp-append-version="true"></script>
<script type="text/javascript" src="~/polyfills.bundle.js" asp-append-version="true"></script>
<script type="text/javascript" src="~/vendor.bundle.js" asp-append-version="true"></script>
<script type="text/javascript" src="~/main.bundle.js" asp-append-version="true"></script>

We are still working out the kinks with all of our referenced packages, but this will get the basic SPA running behind asp.net auth.

Share:
11,436
Daniel
Author by

Daniel

"if it compiles, it is ready to be shipped" 17 years of software development — coffee lover — father of two kids.

Updated on June 11, 2022

Comments

  • Daniel
    Daniel almost 2 years

    I am using the 'new' project templates for angular SPA applications in dotnet core 2.1 as written in the article Use the Angular project template with ASP.NET Core.

    But this article doesn't mention anything about securing the SPA itself. All information i find is about securing a WEBAPI but first of all i am interested in securing the SPA.

    That means: When i open up my SPA e.g. https://localhost:44329/ i would like to be redirected to the authorization server immediatly instead of clicking some button that will do the authentication.

    Background:

    • I have to ensure that only a authenticated users are allowed to see the SPA.
    • I want to use Authorization Code Grant to get refresh tokens from my authorization server.
    • I cannot use Implicit Grant because refresh tokens cannot be kept private on the browser

    Current approach is to enforce a MVC policy that requires a authenticated user. But this can only be applied to a MVC Controller. That's why i added HomeController to serve the first request.

    See project structure:

    enter image description here

    My Startup.cs:

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = "CustomScheme";
            })
            .AddCookie()
            .AddOAuth("CustomScheme", options =>
            {
                // Removed for brevity
            });
    
        services.AddMvc(config =>
        {
            // Require a authenticated user
            var policy = new AuthorizationPolicyBuilder()
                .RequireAuthenticatedUser()
                .Build();
            config.Filters.Add(new AuthorizeFilter(policy));
        });
    
        // In production, the Angular files will be served from this directory
        services.AddSpaStaticFiles(configuration =>
        {
            configuration.RootPath = "ClientApp/dist";
        });
    }
    
    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            app.UseExceptionHandler("/Home/Error");
        }
    
        app.UseAuthentication();
    
        app.UseStaticFiles();
        app.UseSpaStaticFiles();
    
        app.UseMvc(routes =>
        {
            routes.MapRoute(
                name: "default",
                template: "{controller=Home}/{action=Index}/{id?}");
        });
    
        app.UseSpa(spa =>
        {
            spa.Options.SourcePath = "ClientApp";
    
            if (env.IsDevelopment())
            {
                spa.UseAngularCliServer(npmScript: "start");
            }
        });
    }
    

    Current behaviour: When i spin up my SPA i'm immediately redirected to my authorization server because of the MVC policy. After successful authentication i see the Index method of the home controller but not my SPA.

    So the question is how should i serve my SPA after i have been redirected from authentication server?

  • Kyle V.
    Kyle V. over 5 years
    Did you ever work out how to reference the bundle.js files in a production build? The SPA seems to output them in the the format main.[random string].bundle.js when in production build so the static reference you have breaks.
  • chris1out
    chris1out over 5 years
    We did, but it's not a perfect solution. You can use the environment variables to determine which versions to load e.g. : <environment names="Development"> <script type="text/javascript" src="~/runtime.js" asp-append-version="true"></script> </environment> <environment names="Staging,Production"> <script type="text/javascript" asp-src-include="~/dist/runtime.*.js" asp-append-version="true"></script> </environment>. Be careful though, if you aren't removing older files the * will load all files matching the pattern, causing conflicts.
  • Kashif Hanif
    Kashif Hanif over 5 years
    It works in production and helps me to set Base URL.Thank you
  • Kram
    Kram almost 5 years
    For azure also need to filter out the sign-in url to avoid endless sign-in loop - and set up CORS headers to allow browsers to work cross domain: app.UseCors(policy => policy.SetIsOriginAllowed(origin => origin == "https://login.microsoftonline.com")); app.UseAuthentication(); app.Use(async (context, next) => { if (!context.User.Identity.IsAuthenticated && context.Request.Path != "/signin-oidc") { await context.ChallengeAsync(OpenIdConnectDefaults.AuthenticationS‌​cheme); } else { await next(); } });
  • Ahmer Ali Ahsan
    Ahmer Ali Ahsan over 4 years
    I've the same question but I'm using aspnet core 3.0 here is my question: stackoverflow.com/questions/58363393/…
  • Ahmer Ali Ahsan
    Ahmer Ali Ahsan over 4 years
    I've the same question but I'm using aspnet core 3.0 here is my question: stackoverflow.com/questions/58363393/…
  • Rob
    Rob over 4 years
    @AhmerAliAhsan my solution is for aspnet core 3.0 preview-8
  • Ahmer Ali Ahsan
    Ahmer Ali Ahsan over 4 years
    @bob would you like to check my question. Maybe you can help me out
  • Rob
    Rob over 4 years
    @AhmerAliAhsan you're not including the Services.AddIdentity part of the solution that I've proposed above (is my guess) - you definitely need to have .AddRoles<IdentityRole>() from memory otherwise the context.User.Identity.IsAuthenticated will always be false
  • Tal
    Tal about 4 years
    How do you initialize the _env variable ?
  • Cirem
    Cirem about 4 years
    @Tal Inject IHostingEnvironment into the constructor and assign to a readonly class variable.
  • torvin
    torvin over 3 years
    @George's middlware won't require authentication on all requests if you put it right above app.UseSpa call (after UseMvc / UseEndpoit)
  • S. ten Brinke
    S. ten Brinke almost 3 years
    Looking at other comments there definitely are other options
  • Roger Far
    Roger Far almost 3 years
    This question was Authorization, not authentication. Using this method will NOT verify any of your claims and policies!
  • Peter Moore
    Peter Moore almost 3 years
    Brilliant! Not sure why this seems to be such an obscure use case but this was the only answer I could find anywhere. Thanks.
  • Peter Moore
    Peter Moore almost 3 years
    Also for .NET5 use app.UseEndpoints(endpoints => { endpoints.MapFallbackToController( "AuthorizedSpaFallBack", controller: "Home");
  • Peter Moore
    Peter Moore almost 3 years
    Is there no way to make this work while debugging? The index.html file doesn't yet exist on disk with all the path substitutions and JS insertions done by the SPA.
  • Peter Moore
    Peter Moore almost 3 years
    Actually this is perfect. The problem with Cirem's answer is I couldn't use it while debugging because the SPA middleware wasn't doing variable substitution/insertion in the HTML file. But this way is perfect. Just make sure this Use comes before UseSPA
  • Cirem
    Cirem almost 3 years
    @PeterMoore, I updated the answer with a solution that might work for you.
  • Peter Moore
    Peter Moore almost 3 years
    Ah, I think I follow. I'll try it out thanks!
  • Hawkzey
    Hawkzey about 2 years
    When I do this, its hitting azure b2c sign in, but the app is still being rendered rather than redirecting to the MS login page >.<