3rd Party Authentication บน Blazor Server

อย่างแรกเลย Blazor server ไม่มี native support สำหรับ OIDC (แต่มี support authen กับ Azure 😒 ผ่าน Identity library) ดังนั้น OIDC Authen ในที่นี้จริงๆแล้วเป็นของ ASP.NET MVC! เพียงแต่ configure ให้ Blazor กับ MVC ใช้ authen cookie ร่วมกัน

Pipeline Configuration

เพิ่ม UseAuthentication กับ UseAuthorization ระหว่าง UseRouting กับ MapXXXX

app.UseRouting();

app.UseAuthentication()
   .UseAuthorization();

app.MapBlazorHub();

เพื่อให้มีการ handle Authorization (สร้าง ClaimsPrincipal) และ check Authorization (ตาม routing – concept ของ Blazor) ก่อนที่จะไปถึงหน้าที่ render จริงๆ

เลือก External Providers

ถ้าเป็น provider ดังๆอยู่แล้วจะมี Nuget package แยกให้ ให้ลองดูที่

https://docs.microsoft.com/en-us/aspnet/core/security/authentication/social/

AspNet.Security.OAuth.Providers/src at dev · aspnet-contrib/AspNet.Security.OAuth.Providers
OAuth 2.0 social authentication providers for ASP.NET Core - AspNet.Security.OAuth.Providers/src at dev · aspnet-contrib/AspNet.Security.OAuth.Providers

แต่ถ้าเป็น OIDC ทั่วไป ให้ใช้ AddOpenIDConnect จาก Nuget package ที่ชื่อ Microsoft.AspNetCore.Authentication.OpenIdConnect (see ref)

builder.Services.AddAuthentication(o => {
            o.DefaultScheme = o.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            o.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
        })
       .AddCookie()
       .AddOpenIdConnect(o => {
            var oidc = builder.Configuration.GetSection(nameof(Oidc)).Get<Oidc>();
            Debug.Assert(oidc is not null);
            
            o.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            o.Authority = "https://YOUR.AUTHORITY.URI";
            o.ClientId = oidc.ClientId;
            o.ClientSecret = oidc.ClientSecret;
            o.SignedOutRedirectUri = oidc.SignedOutRedirectUri;
            o.ResponseType = OpenIdConnectResponseType.Code;
            o.SaveTokens = true;
            o.Scope.Add("openid");
            o.Scope.Add("profile");
            o.Scope.Add("email");
            o.Scope.Add("your-app-scope");     // 1
            o.UseTokenLifetime = true;         // 2
            o.TokenValidationParameters = new(){
                ValidAudience = "YOUR APP AUDIENCE IF ANY",  // 3
                RequireExpirationTime = true,  // 4
                RequireSignedTokens = true
            };
            
            o.GetClaimsFromUserInfoEndpoint = true;  // 5
            
            o.Events.OnTokenValidated = ctx => { // 6
                var accessToken = ctx.TokenEndpointResponse!.AccessToken;
                var identity = (ClaimsIdentity)ctx.Principal!.Identity!;
                var jwtHandler = new JwtSecurityTokenHandler();
                var jwt = jwtHandler.ReadJwtToken(accessToken);
                var permissions = jwt.Claims.GetPermissions().Select(c => new Claim("permission", c.Value));
                var roles = jwt.Claims.GetClaims("role").Select(c => new Claim(identity.RoleClaimType, c.Value));
                identity.AddClaims(permissions);
                identity.AddClaims(roles);
                
                return Task.CompletedTask;
            };
        });
        
//------ snipped -----
public static class AppClaimTypes
{
    public const string Permission = "permissions"; // "permissions" is what Auth0 used by default

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static IEnumerable<Claim> GetPermissions(this IEnumerable<Claim> claims) => claims.GetClaims(Permission);
    
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static IEnumerable<Claim> GetClaims(this IEnumerable<Claim> claims, string type) => claims.Where(c => c.Type == type);
}

1) สามารถเพิ่ม scope ต่างๆที่อยาก request ได้ที่นี่

2) สำหรับคนเขียนเองอยากให้ lifetime ของ cookie กับ token เป็นตัวเดียวกัน (เผื่อเวลาเอา access token ไปใช้ call API) ถ้า set เป็น false ก็เป็นไปได้ว่า cookie อาจจะอยู่นานกว่า access token

3) ถ้าอยากใช้ access token กับ API จะต้องมี aud (Audience) ส่งมาด้วย สามารถ check ไว้ตรงนี้ได้เลย

4) RequireExpirationTime กับ RequireSignedTokens มีไว้เพื่อ ensure ว่า token นี้ปลอดภัย เป็นความชอบส่วนตัว

5) Fetch profile fields ต่างๆลง ClaimsPrincipal ให้ด้วย แต่ขานี้จะ call UserInfo บน IdP เพิ่ม ทำให้ login ช้าลงอีกนิด

6)  สำหรับทำ claims transformation ... สำหรับคนที่อยากให้ claims จาก access token มาอยู่ใน ClaimsPrincipal ด้วย สามารถทำได้ที่ steps นี้ ตัวอย่างนี้ IdP ของคนเขียนไม่ยอม inject role กับ permissions ลง id_token ทำให้ต้อง copy จาก access token มาใส่ใน ClaimIdentity เอง

Handling authentication flow

App.razor

เราต้องใช้ CascadingAuthenticationState Blazor component สำหรับ pass authentication state ไปให้ view ที่อยู่ล่างๆ และใช้ AuthorizeRouteView เพื่อ handle auth state cases

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(App).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                <Authorizing>Authorizing...</Authorizing>
                <NotAuthorized>
                    @if (context.User.Identity?.IsAuthenticated == true) {
                        <p role="alert">Not authorized!</p>
                    }
                    else {
                        <RedirectToLogin />
                    }
                </NotAuthorized> 
            </AuthorizeRouteView>
            <FocusOnNavigate RouteData="@routeData" Selector="h1"/>
        </Found>
        <NotFound>
            <PageTitle>Not found</PageTitle>
            <LayoutView Layout="@typeof(MainLayout)">
                <p role="alert">Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

สังเกตจังหวะ NotAuthorized จะขึ้น message เลยถ้า user authen ไปแล้ว แสดงว่าไม่มีสิทธิ์จริงๆ แต่ถ้าไม่อย่างนั้น redirect ไป login ผ่าน custom Blazor component RedirectToLogin

@inject NavigationManager Navigation

@code {
    protected override void OnInitialized()
    {
        Navigation.NavigateTo($"authentication/login?returnUri={Uri.EscapeDataString(Navigation.Uri)}", forceLoad: true);
    }
}

Source ของ RedirectToLogin.razor

Handle Login/Logout

ส่วนนี้จะเป็น part ของ Razor แล้ว ให้ทำหน้า Pages/AuthenticationModel.cshtml

@page "/authentication/{action}"
@model YOUR.NAMESPACE.AuthenticationModel

และ code สำหรับหน้านี้ Pages/AuthenticationModel.cshtml.cs

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace YOUR.NAMESPACE.Pages;

public class AuthenticationModel : PageModel
{
    public async Task<IActionResult> OnGet(string action, [FromQuery] string returnUri = "/") {
        switch (action) {
            case "login":
                if (User.Identity?.IsAuthenticated == true)
                    return Redirect(returnUri);
                await HttpContext.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, new(){ RedirectUri = returnUri });
                break;
            case "logout":
                await HttpContext.SignOutAsync();
                return Redirect("/");
        }
        return Page();
    }
}

page นี้รับ action สองอย่างคือ login กับ logout

ที่เหลือก็ configure Authorize

จากนั้นก็เป็น step มาตรฐานสำหรับป้องกัน page ต่างๆ ได้แก่การแปะ attribute Authorize หรือ AllowAnonymous ตามที่ต่างๆ และการกำหนด Authorization Policy หาอ่านได้ทั่วไป