asp.net-coreblazorblazor-server-sideasp.net-authentication

OIDC authentication in server-side Blazor


How can I use OIDC authentication in server-side Blazor?

I used this method, but somehow it's not right because @attribute [AllowAnonymous] doesn't really work. So I used the [Authorized] attribute instead of [AllowAnonymous] and then removed RequireAuthenticatedUser, but OIDC does not redirect the client to the server login page.

I checked Steve Sanderson's GitHub article about authentication and authorization in Blazor, but he doesn't talk about OIDC.

Here is my Startup class:

services.AddAuthentication(config =>
{
    config.DefaultScheme = "Cookie";
    config.DefaultChallengeScheme = "oidc";
})
    .AddCookie("Cookie")
    .AddOpenIdConnect("oidc", config =>
    {
        config.Authority = "https://localhost:44313/";
        config.ClientId = "client";
        config.ClientSecret = "secret";
        config.SaveTokens = true;
        config.ResponseType = "code";
        config.SignedOutCallbackPath = "/";
        config.Scope.Add("openid");
        config.Scope.Add("api1");
        config.Scope.Add("offline_access");
    });

services.AddMvcCore(options =>
{
    var policy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser() // site-wide auth
        .Build();
    options.Filters.Add(new AuthorizeFilter(policy));
});

Solution

  • You'll need to provide an authentication challenge request mechanism that enables redirection to an authenticating agent such as IdentityServer. This is only possible with HttpContext, which is not available in SignalR (Blazor Server App). To solve this issue we'll add a couple of Razor pages where the HttpContext is available.

    First, create a Blazor Server App.

    Run the NuGet command Install-Package Microsoft.AspNetCore.Authentication.OpenIdConnect -Version 3.1.0 or later.

    Create a component named LoginDisplay (LoginDisplay.razor), and place it in the Shared folder. This component is used in the MainLayout component:

    <AuthorizeView>
        <Authorized>
            <a href="logout">Hello, @context.User.Identity.Name !</a>
            <form method="get" action="logout">
                <button type="submit" class="nav-link btn btn-link">Log 
                       out</button>
            </form>
        </Authorized>
        <NotAuthorized>
            <a href="login?redirectUri=/">Log in</a>
        </NotAuthorized>
     </AuthorizeView>
    

    Add the LoginDisplay component to the MainLayout component, just above the About anchor element, like this:

    <div class="top-row px-4">
        <LoginDisplay />
        <a href="https://learn.microsoft.com/aspnet/" target="_blank">About</a>
    </div>
    

    Note: In order to redirect requests for login and for logout to IdentityServer, we have to create two Razor pages as follows:

    1. Create a Login Razor page Login.cshtml (Login.cshtml.cs) and place them in the Pages folder as follows:

      Login.cshtml.cs:

      using Microsoft.AspNetCore.Authentication;
      using Microsoft.AspNetCore.Authentication.OpenIdConnect;
      using Microsoft.AspNetCore.Authentication.Cookies;
      using Microsoft.IdentityModel.Tokens;
      
      public class LoginModel : PageModel
      {
          public async Task OnGet(string redirectUri)
          {
              await HttpContext.ChallengeAsync("oidc", new 
                  AuthenticationProperties { RedirectUri = redirectUri } );
          }  
      }
      

      This code starts the challenge for the Open Id Connect authentication scheme you defined in the Startup class.

    2. Create a Logout Razor page Logout.cshtml (Logout.cshtml.cs) and place them in the Pages folder as well:

      Logout.cshtml.cs:

      using Microsoft.AspNetCore.Authentication;
      
      public class LogoutModel : PageModel
      {
          public async Task<IActionResult> OnGetAsync()
          {
              await HttpContext.SignOutAsync();
              return Redirect("/");
          }
      }
      

      This code signs you out, redirecting you to the Home page of your Blazor app.

    Replace the code in App.razor with the following code:

    @inject NavigationManager NavigationManager
    
    <CascadingAuthenticationState>
    <Router AppAssembly="@typeof(Program).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    @{
                        var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
                        
                        NavigationManager.NavigateTo($"login?redirectUri={returnUrl}", forceLoad: true);
                        
                    }
                </NotAuthorized>
                <Authorizing>
                    Wait...
                </Authorizing>
            </AuthorizeRouteView>
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
    </CascadingAuthenticationState>
    

    Replace the code in the Startup class with the following:

    using Microsoft.AspNetCore.Authentication.OpenIdConnect; 
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Authentication.Cookies;
    using Microsoft.IdentityModel.Tokens;
    using Microsoft.AspNetCore.Builder;
    using Microsoft.AspNetCore.Hosting;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Hosting;
    
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }
    
        public IConfiguration Configuration { get; }
    
        // This method gets called by the runtime. Use this method to add services to the container.
        // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddRazorPages();
            services.AddServerSideBlazor();
            services.AddAuthorizationCore();
            services.AddSingleton<WeatherForecastService>();
                        
            services.AddAuthentication(sharedOptions =>
            {
                sharedOptions.DefaultAuthenticateScheme = 
                     CookieAuthenticationDefaults.AuthenticationScheme;
                sharedOptions.DefaultSignInScheme = 
                    CookieAuthenticationDefaults.AuthenticationScheme;
                sharedOptions.DefaultChallengeScheme = 
                   OpenIdConnectDefaults.AuthenticationScheme;
            })
            .AddCookie()
            .AddOpenIdConnect("oidc", options =>
            {
                options.Authority = "https://demo.identityserver.io/";
                options.ClientId = "interactive.confidential.short"; 
                options.ClientSecret = "secret";
                options.ResponseType = "code";
                options.SaveTokens = true;
                options.GetClaimsFromUserInfoEndpoint = true;
                options.UseTokenLifetime = false;
                options.Scope.Add("openid");
                options.Scope.Add("profile");
                options.TokenValidationParameters = new 
                    TokenValidationParameters
                    {
                        NameClaimType = "name"
                    };
                        
                 options.Events = new OpenIdConnectEvents
                 {
                     OnAccessDenied = context =>
                     {
                         context.HandleResponse();
                         context.Response.Redirect("/");
                         return Task.CompletedTask;
                     }
                 };
             });
        }
    
        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Error");
                // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
                app.UseHsts();
            }
    
            app.UseHttpsRedirection();
            app.UseStaticFiles();
            app.UseRouting();
            app.UseAuthentication();
            app.UseAuthorization();           
    
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapBlazorHub();
                endpoints.MapFallbackToPage("/_Host");
            });
        }
    }
    

    IMPORTANT: in all the code samples above you'll have to add using statements as necessary. Most of them are provided by default. The using provided here are those necessary to enable the authentication and authorization flow.

    Note: While you're experimenting with your app, you should clear the browsing data, if you want to be redirected to the identity server's login page, otherwise, your browser may use the cached data. Remember, this is a cookie-based authorization mechanism.

    Note that creating a login mechanism as is done here does not make your app more secured than before. Any user can access your web resources without needing to log in at all. In order to secure parts of your web site, you have to implement authorization as well, conventionally, an authenticated user is authorized to access secured resource, unless other measures are implemented, such as roles, policies, etc. The following is a demonstration how you can secure your Fetchdata page from unauthorized users (again, authenticated user is considered authorized to access the Fetchdata page).

    At the top of the Fetchdata component page add the @attribute directive for the Authorize attribute, like this: @attribute [Authorize] When an unauthenticated user tries to access the Fetchdata page, the AuthorizeRouteView.NotAuthorized delegate property is executed, so we can add some code to redirect the user to the same identity server's login page to authenticate.

    The code within the NotAuthorized element looks like this:

    <NotAuthorized>
        @{
            var returnUrl = 
            NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
            NavigationManager.NavigateTo($"login?redirectUri= 
                                  {returnUrl}", forceLoad: true);
         }
    </NotAuthorized>
    

    This retrieves the URL of the last page you were trying to access, the FetchData page, and then navigates to the Login Razor page from which a password challenge is performed, that is, the user is redirected to the identity server's login page to authenticate.

    After the user has authenticated they are redirected to the FetchData page.