BlazorでシンプルにJWT認証・認可(.Net 8 WebApi利用)

## １．この記事の目的
Blazor Web APPのデフォルトの認証種類：「個別のアカウント」は多機能なために複雑。
そこで、シンプルに以下の機能のみを実装したサンプルです。
* 認証済みの場合のみページを表示
* 未認証ならログインページへリダイレクト
* 認証されているユーザー名を表示
* 認証されているRoleを表示
* 認証状態によって自動制御
* WebAPIコールもJWTを渡し認可された場合のみデータ取得

Blazor Cookie認証もまとめました
https://qiita.com/masayahak/items/b5d363d4fc4f33e6f208


## ２．前提条件
* Visual studio 2022 Version 17.12.2
* .Net 8
* UI：Blazor Web App
* Blazor rendermode：InteractiveServerRenderMode
* WebAPI：ASP.NET Core Web API
* 認証・認可にはJWTを利用
* 認証ロジックはここでは単純化し、あえてデータベースを利用しない

### ２．１ ソリューションの構成
BlazorJwt（ソリューション）
　├─ BlazorJwt.Api （WebAPI用のプロジェクト ASP.NET Core Web API）  
　└─ BlazorJwt.Web （Blazor用のプロジェクト Blazor Web App）

### ２．２ ソリューションのソース
 https://github.com/masayahak/BlazorJwt

* 全ソースを添付してます
* Databaseなど不要なのでそのまま実行できます
* マルチスタートアップの設定だけしてください

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/45b59426-ba38-e2ba-237d-b2629e483bf8.png)

## ３．仕様
### ３．１ WebAPI側の仕様
■ 認証用WebAPI
* ユーザー名とパスワードを受け取り、認証に成功した場合のみJWTを発行

:::note alert
認証に失敗した場合は401エラーを返す。
:::

■ データ取得用WebAPI
* JWTを受け取り認可に成功した場合のみデータを返す

:::note alert
認可に失敗した場合は401エラーを返す。
:::

### ３．２ Blazor側の仕様
:::note info
ページリクエストを全て判定し、未認証ならログインページへリダイレクト。
:::
* ログイン用WebAPIを実行し、受け取ったJWTをブラウザのローカルストレージに保管
（併せてAuthenticationStateProviderに認証者情報を通知する）
* データ取得用WebAPIをコールする時は保管したJWTを自動的に付与
* ログアウト時はローカルストレージに保管したJWTを削除
（併せてAuthenticationStateProviderの認証者情報を消去する）



## ４．ローカルストレージ(ProtectedLocalStorage)とは
.Netの提供する標準的な機能です。
Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage.ProtectedLocalStorage
* データはサーバー側で暗号化され、クライアント側に保存される
* データの復号化もサーバー側で行われる
* データの整合性と機密性を保護します

:::note alert
直接ブラウザ側でローカルストレージを変更すると、、、
* 暗号化されたデータが破損または不正な状態になる
* サーバー側で復号化する際に、データの復号化に失敗し、例外が発生する
:::



## ５．実行イメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/cf620b12-4482-fb1a-6884-7b0605b50793.png)

ログインに成功すると、ローカルストレージにtoken(JWT)が記録される。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/899f5565-a47e-8a4a-e1ef-715920d16e01.png)

もしtoken（JWT）の値をブラウザから強制的に変更すると
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/3d0aea80-9ef2-c067-d640-a4740f0b4289.png)

（例外をキャッチし）強制的にtokenを消去しログイン画面へリダイレクト
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/41ac752d-af73-1023-7a74-7b4d13ad2f5f.png)



## ６．Blazor側の構成

### ６．１ Layout/MainLayout.razor
:::note info
* ページリクエスト時に必ず認証状態を判定する
* 未認証ならログインページへリダイレクト
:::

:::note alert
この制御により未認証のまま不正なURLをリクエストしても強制的にリダイレクトします。
:::

ログアウト中にURL「localhost:7009/weather」をGETするとログイン画面へリダイレクト
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/54e1748a-6a5b-adc7-1a1a-ed837d1197c9.png)

### ６．２ OnInitializedAsyncのoverride 
* 「カスタマイズしたAuthenticationStateProvider」から認証状態を取得する
* 認証状態が未認証なら強制的にログインページへリダイレクト

MainLayout.razor
```html
@inherits LayoutComponentBase
@using Microsoft.AspNetCore.Components.Authorization
@using System.Security.Claims
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation

@if (isAuthenticated)
{
    <div class="page">
        <div class="sidebar">
            <NavMenu />
        </div>

        <main>
            <div class="top-row px-4">
                <div>@userName (権限 @userRole)</div>
                <a href="/logout">LogOut</a>
            </div>

            <article class="content px-4">
                @Body
            </article>
        </main>
    </div>

    <div id="blazor-error-ui">
        An unhandled error has occurred.
        <a href="" class="reload">Reload</a>
        <a class="dismiss">🗙</a>
    </div>
}

@code {
    private bool isAuthenticated { get; set; }
    private string? userName;
    private string? userRole;

    // すべてのページリクエスト時に認証状態を確認し、未認証ならログインページへリダイレクト
    protected override async Task OnInitializedAsync()
    {
        // 認証状態を取得
        var authState = await ((CustomAuthStateProvider)AuthStateProvider).GetAuthenticationStateAsync();
        var user = authState.User;

        // 未認証ならログインページへリダイレクト
        if (user.Identity?.IsAuthenticated != true)
        {
            Navigation.NavigateTo("/login");
        }

        // ----------------- 以下認証済みの場合 -----------------
        isAuthenticated = true;

        //ユーザー名
        userName = user.Identity?.Name;
        //ロール
        var roleClaim = user.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Role);
        userRole = roleClaim?.Value ?? "No Role";
    }
}
```

### ６．３ 認証状態のカスタマイズ
:::note info
Blazorではrazor構文内で「認証済みなら表示」や「認証者の権限により表示」を専用のタグで簡単に記述できます。（Authorizedタグなど）
この恩恵を受けるために、.Netの提供するAuthenticationStateProviderをカスタマイズします。
:::

認可状態を判定するタグのサンプル
```HTML
<AuthorizeView>
    <Authorized>
        <p>@context.User.Identity?.Name として認可されています</p>
    </Authorized>
    <NotAuthorized>
        <p>認可されていません</p>
    </NotAuthorized>
</AuthorizeView>
```

権限（Role）に基づき判定するタグのサンプル
```HTML
<AuthorizeView Roles="Administrator">
    <p>権限:Administratorは @ROW_COUNT_ADMIN 件の天気予報を表示します。</p>
</AuthorizeView>
<AuthorizeView Roles="User">
    <p>権限:Userは @ROW_COUNT_USER 件の天気予報を表示します。</p>
</AuthorizeView>
```

### ６．４ AuthenticationStateProviderのカスタマイズ
#### A) 認証状態の取得
* AuthenticationStateProviderのGetAuthenticationStateAsyncをoverride
* ローカルストレージからJWTを取得し認証状態を取得

:::note alert
JWT取得時に異常を検知した場合、強制的にローカルストレージを消去します。
:::

#### B) ログイン時の挙動
* JWTを受け取りローカルストレージへ保管
　（併せてAuthenticationStateを更新）

#### C) ログアウト時の挙動
* ローカルストレージのJWTを破棄
　（併せてAuthenticationStateを更新）

CustomAuthStateProvider.cs
```C#
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;

namespace BlazorJwt.Web.Auth
{
    // ============================================================
    // Blazor上でのログイン状態をJWT取得結果から判定＋格納する
    // AuthenticationStateProviderを継承することで、
    // Blazor特有の<AuthorizeView>タグなのに対応させる。
    // ============================================================
    public class CustomAuthStateProvider(ProtectedLocalStorage localStorage) 
        : Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider
    {
        private const string LOCAL_STORAGE_KEY = "token";

        // ローカルストレージのJWTをもとに認証状態を取得する
        public async override Task<AuthenticationState> GetAuthenticationStateAsync()
        {
            string token = string.Empty;
            try
            {
                var tokenResult = await localStorage.GetAsync<string>(LOCAL_STORAGE_KEY);
                token = tokenResult.Value ?? string.Empty;
            }

            // tokenの書き換えなどなんらかの異常を検知
            catch (Exception)
            {
                try
                {
                    // ローカルストレージを消去
                    await localStorage.DeleteAsync(LOCAL_STORAGE_KEY);
                }
                catch { }
            }

            var identity = string.IsNullOrEmpty(token)
                ? new ClaimsIdentity()
                : GetClaimsIdentity(token);

            var user = new ClaimsPrincipal(identity);
            return new AuthenticationState(user);
        }

        private ClaimsIdentity GetClaimsIdentity(string token)
        {
            var handler = new JwtSecurityTokenHandler();
            var jwtToken = handler.ReadJwtToken(token);
            var claims = jwtToken.Claims;
            return new ClaimsIdentity(claims, "jwt", ClaimTypes.Name, ClaimTypes.Role);
        }

        // ログイン成功時にJWTを受け取り、ローカルストレージへJWTを書き込み認証状態を反映する。
        public async Task MarkUserAsAuthenticated(string token)
        {
            await localStorage.SetAsync(LOCAL_STORAGE_KEY, token);
            var identity = GetClaimsIdentity(token);
            var user = new ClaimsPrincipal(identity);
            NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(user)));
        }

        // ログアウト時にローカルストレージのJWTを削除し、認証状態を反映する。
        public async Task MarkUserAsLoggedOut()
        {
            await localStorage.DeleteAsync(LOCAL_STORAGE_KEY);

            // 認証者なしにする
            var identity = new ClaimsIdentity();
            var user = new ClaimsPrincipal(identity);
            NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(user)));
        }
    }
}
```



### ６．５ Weather.razor（データ取得ページ）

#### A) カスタマイズされたHttpClient(ApiClient)を利用する


Weather.razor
```html
@page "/weather"
@using BlazorJwt.Api.Models
@rendermode InteractiveServer
@inject ApiClient ApiClient
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation

<PageTitle>Weather</PageTitle>

<h1>Weather</h1>

@* 認可状態を判定するサンプル *@
<AuthorizeView>
    <Authorized>
        <p>@context.User.Identity?.Name として認可されています</p>
    </Authorized>
    <NotAuthorized>
        <p>認可されていません</p>
    </NotAuthorized>
</AuthorizeView>


@* 認証ユーザーロールに基づき判定するサンプル *@
<AuthorizeView Roles="Administrator">
    <p>権限:Administratorは @ROW_COUNT_ADMIN 件の天気予報を表示します。</p>
</AuthorizeView>
<AuthorizeView Roles="User">
    <p>権限:Userは @ROW_COUNT_USER 件の天気予報を表示します。</p>
</AuthorizeView>


@if (forecasts == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Date</th>
                <th>Temp. (C)</th>
                <th>Temp. (F)</th>
                <th>Summary</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var forecast in forecasts)
            {
                <tr>
                    <td>@forecast.Date.ToShortDateString()</td>
                    <td>@forecast.TemperatureC</td>
                    <td>@forecast.TemperatureF</td>
                    <td>@forecast.Summary</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private WeatherModel[]? forecasts;

    private const int ROW_COUNT_ADMIN = 100;
    private const int ROW_COUNT_USER = 5;

    protected override async Task OnInitializedAsync()
    {        
        // 認証状態を取得
        var authState = await ((CustomAuthStateProvider)AuthStateProvider).GetAuthenticationStateAsync();

        // ロール
        var isAdmin = authState.User.IsInRole("Administrator");
        var isUser = authState.User.IsInRole("User");
        var rowCount = isAdmin ? ROW_COUNT_ADMIN : isUser ? ROW_COUNT_USER : 0;

        // WebAPIコール
        forecasts = await ApiClient.PostAsync<WeatherModel[], int>("/api/weather/all", rowCount);

    }

}
```

### ６．６ ApiClient（HttpClientのカスタマイズ）

#### A) httpClient通信の拡張
* httpClient.GetFromJsonAsyncなどGET/POST/PUT/DELETEを全てラップ

#### B) Authorizationヘッダーを自動付与
* ローカルストレージからJWTを取り出し、Authorizationヘッダーへ付与

ApiClient.cs
```C#
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
using Newtonsoft.Json;
using System.Net.Http.Headers;

namespace BlazorJwt.Web.Auth
{
    // ============================================================
    // API CALL用 
    // ------------------------------------------------------------
    // httpClient.GetFromJsonAsyncなどをラップし
    // 自動的にローカルストレージに保存したJWTを付与する
    // ============================================================
    public class ApiClient(
            HttpClient httpClient,
            ProtectedLocalStorage localStorage
        ) : System.Net.Http.HttpClient
    {

        // JWTを付与
        private async Task SetAuthorizeHeader()
        {
            var token = (await localStorage.GetAsync<string>("token")).Value;
            if (token is null) return;

            httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
        }

        // ------------------ 以下、GET/POST/PUT/DELETE -----------------------
        public async Task<T?> GetFromJsonAsync<T>(string path)
        {
            await SetAuthorizeHeader();
            return await httpClient.GetFromJsonAsync<T>(path);
        }
        public async Task<T1?> PostAsync<T1, T2>(string path, T2 postModel)
        {
            await SetAuthorizeHeader();
            var res = await httpClient.PostAsJsonAsync(path, postModel);
            if (res != null && res.IsSuccessStatusCode)
            {
                var content = await res.Content.ReadAsStringAsync();
                if (typeof(T1) == typeof(string))
                {
                    // 直接文字列として返す
                    return (T1)(object)content;
                }
                else
                {
                    // 指定された型にデシリアライズ
                    return JsonConvert.DeserializeObject<T1>(content);
                }
            }
            return default;
        }

        public async Task<T1?> PutAsync<T1, T2>(string path, T2 postModel)
        {
            await SetAuthorizeHeader();
            var res = await httpClient.PutAsJsonAsync(path, postModel);
            if (res != null && res.IsSuccessStatusCode)
            {
                var content = await res.Content.ReadAsStringAsync();
                if (typeof(T1) == typeof(string))
                {
                    // 直接文字列として返す
                    return (T1)(object)content;
                }
                else
                {
                    // 指定された型にデシリアライズ
                    return JsonConvert.DeserializeObject<T1>(content);
                }
            }
            return default;
        }
        public async Task<T?> DeleteAsync<T>(string path)
        {
            await SetAuthorizeHeader();
            return await httpClient.DeleteFromJsonAsync<T>(path);
        }
    }
}
```



## ７．WebAPI
### ７．１ Login（JWT発行ロジック）
Blazor側での利用しやすさを考えて、Claimsには
・ClaimTypes.Name
・ClaimTypes.Role
の２つを含める。

AuthController.cs
```C#
using BlazorJwt.Api.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

namespace BlazorJwt.Api.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class AuthController(IConfiguration configuration) : ControllerBase
    {
        [HttpPost("login")]
        public async Task<ActionResult<string>> Login([FromBody] LoginModel loginModel)
        {
            var token = GenerateJwtToken(loginModel.Username, loginModel.Password);
            await Task.CompletedTask;

            if (token != string.Empty)
            {
                return Ok(token);
            }
            else
            {
                return Unauthorized(string.Empty);
            }
        }

        // Logoutは不要
        // クライアント側で保持していたJWTをクライアント側で削除し
        // クライアント側のステータスのみ更新すればログアウトは成功
        // サーバー側のログアウト処理はない


        // ========================================================
        // JWT TOKEN GENERATOR
        // ========================================================

        private string GenerateJwtToken(string userName, string password)
        {
            List<Claim>? claims = GenerateClaims(userName, password);
            if (claims is null) { return string.Empty; }

            var jwtSettings = configuration.GetSection("JwtSettings");
            var secretKey = jwtSettings["SecretKey"] ?? string.Empty;
            var expiryMinutes = int.Parse(jwtSettings["ExpiryInMinutes"] ?? string.Empty);

            var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
            var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);

            var token = new JwtSecurityToken(
                claims: claims,
                expires: DateTime.UtcNow.AddMinutes(expiryMinutes),
                signingCredentials: credentials
                );
            return new JwtSecurityTokenHandler().WriteToken(token);
        }

        private List<Claim>? GenerateClaims(string userName, string password)
        {
            // 認証チェックはダミー
            if (userName.StartsWith("admin@") && password == "admin")
            {
                var claims = new List<Claim>
                {
                    new Claim(ClaimTypes.Name, userName),
                    new Claim(ClaimTypes.Role, "Administrator")
                };
                return claims;
            }
            else if (userName.StartsWith("user@") && password == "user")
            {
                var claims = new List<Claim>
                {
                    new Claim(ClaimTypes.Name, userName),
                    new Claim(ClaimTypes.Role, "User")
                };
                return claims;
            }

            return null;
        }

    }
}
```


### ７．２ WebAPIデータ取得
Authorizeアノテーションにて認可判定

WeatherController.cs
```C#
using BlazorJwt.Api.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace BlazorJwt.Api.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    [Authorize]
    public class WeatherController() : ControllerBase
    {
        [HttpPost("all")]
        public async Task<ActionResult<WeatherModel[]>> All([FromBody] int ListCount)
        {
            var startDate = DateOnly.FromDateTime(DateTime.Now);
            var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
            WeatherModel[] weathers = Enumerable.Range(1, ListCount).Select(index => new WeatherModel
            {
                Date = startDate.AddDays(index),
                TemperatureC = Random.Shared.Next(-20, 55),
                Summary = summaries[Random.Shared.Next(summaries.Length)]
            }).ToArray();

            await Task.CompletedTask;
            return Ok(weathers);
        }
    }
}
```

### ７．３ WebAPI動作確認用HTTPファイル

BlazorJwt.Api.http
```
@BlazorJwt.Api_HostAddress = https://localhost:7385/api


// ==================== Weather ===================
POST {{BlazorJwt.Api_HostAddress}}/weather/all
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiYWRtaW5AdGVzdC5jb20iLCJodHRwOi8vc2NoZW1hcy5taWNyb3NvZnQuY29tL3dzLzIwMDgvMDYvaWRlbnRpdHkvY2xhaW1zL3JvbGUiOiJhZG1pbiIsImV4cCI6MTczMjc2MDI2OH0.IbWGMpZzCEgdyMvTBocBs1kf3CGNG6ccedddrOY08aI
 100
###

// ==================== ログイン ===================
// ------ admin -------------------------------
POST {{BlazorJwt.Api_HostAddress}}/Auth/login
Content-Type: application/json
{
    "Username": "admin@test.com",
    "Password": "admin"
}
###

// ------ user -------------------------------
POST {{BlazorJwt.Api_HostAddress}}/Auth/login
Content-Type: application/json
{
    "Username": "user@test.com",
    "Password": "user"
}
###

// ------ ログイン失敗 -------------------------------
POST {{BlazorJwt.Api_HostAddress}}/Auth/login
Content-Type: application/json
{
    "Username": "user@test.com",
    "Password": "xxxxx"
}
###
```


### ７．４ WebAPI動作確認

adminでのログインを要求し、JWTが取得できていることを確認します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/16c9f367-62e5-08d4-82dd-87d99aeb88ee.png)

認証できないユーザー名とパスワードの組み合わせをPOSTし、401エラーになることを確認します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/652fe58c-4108-76f3-0a32-18580e02a656.png)

ログインへ成功したときに取得したJWTを貼り付け、データ取得用APIが実行できることを確認します。
（ Authorization: Bearer {ここにJWTを張り付ける} ）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/61cf6b65-3a21-907a-f732-de8f184de8b0.png)

JWTの一部でも文字列を変更した場合、401エラーになることを確認します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/7b982d22-0a32-30a3-1469-a34455c9134f.png)




## ８．参考（Blazor側の ログイン／ログアウト）

### ８．１ Login.razor

```html
@page "/login"
@using BlazorJwt.Web.Auth
@using BlazorJwt.Web.Components.Layout
@using BlazorJwt.Api.Models
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Identity.Data
@layout EmptyLayout
@inject ApiClient ApiClient
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthStateProvider

<div class="p-5">
    <div class="col-md-4">
        <div class="form-group">
            <p>管理者権限でログイン     admin@test.com / admin</p>
            <p>ユーザー権限でログイン   user@test.com / user</p>
        </div>
        <EditForm Model="@loginModel" FormName="Login" OnValidSubmit="LoginAsync">
            <div class="form-group">
                <label for="username">Username</label>
                <InputText id="username" class="form-control" @bind-Value="loginModel.Username" />
            </div>
            <div class="form-group">
                <label for="password">Password</label>
                <InputText id="password" class="form-control" type="password" @bind-Value="loginModel.Password" />
            </div>
            <button type="submit" class="btn btn-primary mt-3">Login</button>
        </EditForm>
    </div>
</div>

@code {
    private LoginModel loginModel = new LoginModel();
    private async Task LoginAsync()
    {
        // ログイン用WebAPI呼び出し
        var token = await ApiClient.PostAsync<string, LoginModel>("/api/auth/login", loginModel);

        if (token != null)
        {
            await ((CustomAuthStateProvider)AuthStateProvider).MarkUserAsAuthenticated(token);
            Navigation.NavigateTo("/");
        }
        else 
        {
            // ログイン失敗
        }
    }
}
```

### ８．２ Logout.razor
```html
@page "/logout"
@using BlazorJwt.Web.Auth
@using Microsoft.AspNetCore.Components.Authorization
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation

@code {
    protected override async Task OnInitializedAsync()
    {
        await ((CustomAuthStateProvider)AuthStateProvider).MarkUserAsLoggedOut();
        Navigation.NavigateTo("/login");
    }
}
```


## ９．参考にさせて頂きました
https://zenn.dev/microsoft/articles/aspnetcore-blazor-dotnet8-tryaddauth

https://www.youtube.com/watch?v=oqpNQxEfz_Y