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/
แต่ถ้าเป็น 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 หาอ่านได้ทั่วไป