OIDC authentication in server-side Blazor
Asked Answered
F

2

9

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));
});
Fluff answered 16/11, 2020 at 6:37 Comment(0)
P
23

The following is a complete and working solution to the question:

First off, 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. More in the answer...

Create a Blazor Server App.

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 follow: 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.

  1. 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 sample 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.

  • Run your app, click on the log in button to authenticate. You are being redirected to IdentityServer test server which allows you to perform an OIDC login. You may enter a user name: bob and password bob, and after click the OK button, you'll be redirected to your home page. Note also that you can use the external login provider Google (try it). Note that after you've logged in with identity server, the LoginDisplay component displays the string "Hello, <your user name>".

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.

Palmitate answered 16/11, 2020 at 12:17 Comment(1)
Legendary !!, worked with keycloak as an oidc prover as wellVilma
C
3

For server-side Blazor, authentication happens on the Razor page on which the Blazor application is hosted. For the default template, this is the _Host.cshtml Razor page which is configured to be the fallback page for server-side routing. Since the page is like a normal Razor page, you can use the [Authorize] or [AllowAnonymous] attributes there.

Any authorization you apply to the _Host.cshtml impacts how the general access to the Blazor app itself is authorized. If you want only authenticated users to access the app, you should require authorization; if you want any non-authenticated users to access the app, you cannot protect the app access itself.

The authorization of the page does not mean that you cannot have a more fine-grained authorization within your app. You can still use different rules and policies for particular components within your application. For that, you can use the <AuthorizeView> component.

There are two common scenarios that are likely for server-side Blazor:

  • Access to the whole Blazor application is limited to authenticated users. Users that are not authenticated should immediately authenticate (e.g. using OIDC) so that no anonymous user hits the app.

    In that case, it should be enough to protect the _Host.cshtml by requiring authenticated users, either through the [Authorize] attribute, or using a convention in the AddRazorPages() call.

    When accessing the Blazor application without being authenticated, the default authorization middleware will cause an authentication challenge and redirect to the OIDC sign-in.

  • Non-authenticated users should be able to access the Blazor application but the Blazor application will use a more detailed authorization using the <AuthorizeView> or IAuthorizationService.

    In this situation, the _Host.cshtml must not be protected since anonymous users need to access it. This also means that the default authorization middleware, which runs as part of the Razor page, will not do anything. So you will have to handle the challenge yourself.

    The “simple” way to do this would be to provide a login link to a different server-side route which will then trigger the authentication challenge and redirect to the OIDC sign-in. For example, you could have a MVC action like this:

    [HttpGet("/login")]
    public IActionResult Login()
        => Challenge();
    

    Within your Blazor app, you could now add a link to this route and allow users to sign in that way:

    <AuthorizeView>
      <Authorized>
        Signed in as @context.User.Identity.Name.
      </Authorized>
      <NotAuthorized>
        <a href="/login">Sign in here</a>
      </NotAuthorized>
    </AuthorizeView>
    
Chicky answered 16/11, 2020 at 8:57 Comment(2)
thank you , but how can I generate returnUrl in second scenario ?Fluff
You can use the NavigationManager in Blazor to access the current URL which you can then append to the /login URL.Chicky

© 2022 - 2024 — McMap. All rights reserved.