Search code examples
c#asp.net-core-mvcazure-active-directoryopenid-connect

How to fix error 404 when logging out on an ASP.NET Core MVC app against Azure AD?


I'm integrating an ASP.NET Core MVC 3.0 app to Azure AD for authentication and authorization adn everything works well, but when I try to sign out, once login.microsoftonline.com signs me out, it redirects to my app and then the following error springs up:

No webpage was found for the web address:

https://localhost:5002/Account/SignOut?page=%2FAccount%2FSignedOut

The path I use to invoke the signout process is /AzureAD/Account/SignOut.

Contents of appsettings.json:

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "[OMITTED]",
    "TenantId": "[OMITTED]",
    "ClientId": "[OMITTED]",
    "CallbackPath": "/signin-oidc"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

These are the contents of my Startup.cs class:

using System.Collections.Generic;
using System.Globalization;
using MySite.WebSite.Helpers;
using MySite.WebSite.Models.Validators;
using MySite.WebSite.Models.ViewModels;
using FluentValidation;
using FluentValidation.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.AzureAD.UI;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Localization;

namespace MySite.WebSite
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.Configure<CookiePolicyOptions>(options =>
            {
                options.CheckConsentNeeded = context => true;
                options.MinimumSameSitePolicy = SameSiteMode.None;
            });

            services
                .AddAuthentication(AzureADDefaults.AuthenticationScheme)
                .AddAzureAD(options => Configuration.Bind("AzureAd", options));

            services.Configure<CookieAuthenticationOptions>(AzureADDefaults.CookieScheme, options =>
            {
                options.AccessDeniedPath = "/Home/AccessDenied";
                options.LogoutPath = "/";
            });

            services.Configure<OpenIdConnectOptions>(AzureADDefaults.OpenIdScheme, options =>
            {
                options.Authority += "/v2.0/";
                options.TokenValidationParameters.ValidateIssuer = false;
            });

            services.AddLocalization(options => options.ResourcesPath = "Resources");
            services
                .AddControllersWithViews(options => options.Filters.Add(GetAuthorizeFilter()))
                .SetCompatibilityVersion(CompatibilityVersion.Version_3_0)
                .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
                .AddDataAnnotationsLocalization()
                .AddFluentValidation();

            services.AddTransient<IValidator<ContactIndexViewModel>, ContactIndexViewModelValidator>();
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
                app.UseHsts();
            }
            app.UseHttpsRedirection();
            app.UseRequestLocalization(GetLocalizationOptions());
            app.UseStaticFiles(GetStaticFileOptions());
            app.UseRouting();
            app.UseAuthentication();
            app.UseAuthorization();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllerRoute(
                    name: "default",
                    pattern: "{controller=Home}/{action=Index}/{id?}");
            });
        }

        private RequestLocalizationOptions GetLocalizationOptions()
        {
            var cookie_request_culture_provider = new CookieRequestCultureProvider
            {
                CookieName = "UserCulture"
            };
            var providers = new List<IRequestCultureProvider>()
            {
                cookie_request_culture_provider,
                new AcceptLanguageHeaderRequestCultureProvider()
            };

            var result = new RequestLocalizationOptions
            {
                RequestCultureProviders = providers,
                SupportedCultures = Cultures.SupportedCultures,
                SupportedUICultures = Cultures.SupportedCultures,
                DefaultRequestCulture = new RequestCulture(Cultures.DefaultCulture)
            };
            return result;
        }

        private StaticFileOptions GetStaticFileOptions()
        {
            var result = new StaticFileOptions
            {
                ServeUnknownFileTypes = true,
                DefaultContentType = "text/plain"
            };
            return result;
        }

        private AuthorizeFilter GetAuthorizeFilter()
        {
            var policy = new AuthorizationPolicyBuilder()
                .RequireAuthenticatedUser()
                .Build();
            var result = new AuthorizeFilter(policy);
            return result;
        }
    }
}


Solution

  • Turns out this is a known issue in Microsoft.AspNetCore.Authentication.AzureAD.UI; that package implements the Azure AD authentication/authorization flow in ASP.NET Core, and part of that is an embedded AccountController (area AzureAD) that takes the signin - signout processes out of your shoulders. Problem is, the SignOut action hardcodes a redirect to /Account/SignOut?page=%2FAccount%2FSignedOut once the signout process is complete, and there's the problem.

    I managed to solve it by implementing a small AccountController (without an area) and adding a single SignOut action that handles the redirect from Microsoft.AspNetCore.Authentication.AzureAD.UI's AccountController:

    [AllowAnonymous]
    public class AccountController : Controller
    {
        [HttpGet]
        public IActionResult SignOut(string page)
        {
            return RedirectToAction("Index", "Home");
        }
    }