.NET 8 の MapIdentityApi を使って独立した Web API で認証基盤を作り、UI は Blazor Server で実装したい——この構成は便利ですが、トークンの保存先や <AuthorizeView> をどう成立させるかで詰まりがちです。本記事では安全に動かすための設計と実装例を整理します。
よくある状況:Web API 側は認証できているのに、Blazor Server 側で「ログイン済み」にならない
Web API 側で Identity の MapIdentityApi(例:app.MapGroup("/account").MapIdentityApi())を公開し、Postman/Swagger からは login/register/2FA/refresh が期待どおり動く。しかし UI を Blazor Server にすると、次の壁にぶつかります。
- API に username/password を投げて access/refresh token を受け取っても、Blazor 側の [Authorize] や <AuthorizeView> が「未ログイン」のまま
- 受け取ったトークンをどこに置き、API 呼び出し時にどう付けるのが安全か分からない
- 2FA が「最初の login は 401(2FA 必須)→ 2FA コード付きで再ログイン」になり、UI 側で一時的に資格情報を持つのが不安
ここで重要なのは、Blazor Server では 「UI の認証状態」 と 「API の認可(Bearer トークン)」 が自動で同期しない、という点です。
結論:Blazor の「認証状態」と API 呼び出し用の「トークン」は分けて管理する
まず設計方針を明確にします。Blazor 側が必要なのは「ログインしているかどうか(ClaimsPrincipal)」であり、API が必要なのは「Bearer トークン」です。この2つは似ていますが、同じ入れ物で持ち回ると事故りやすいです。
| 区分 | 目的 | 主な中身 | おすすめの保管 | よくある失敗 |
|---|---|---|---|---|
| Blazor の認証状態 | [Authorize] / <AuthorizeView> を成立させる | ユーザー名、表示名、ロールなどのクレーム | AuthenticationStateProvider(必要なら永続化) | トークンをクレームに入れる/UI が token を持つ前提で組む |
| API 呼び出し用トークン | Web API の RequireAuthorization を通す | access token / refresh token | TokenStore(Scoped)+必要に応じて ProtectedBrowserStorage やサーバー側ストア | localStorage に生の refresh token を置く/ログに出す/複数タブで競合 |
ポイントは次の2つです。
- Blazor の認証状態は AuthenticationStateProvider で「ログイン済み」を通知する(トークンの有無とは別に成立させる)
- API の呼び出しは TokenStore の access token を Authorization ヘッダに付ける(必要なら refresh で更新する)
全体像:Blazor Server を「UI と中継役」として組み立てる
実装の流れを、あえて単純化して描くと次のようになります。
- ログイン画面(Blazor Server)から Web API の
/account/loginに username/password を送る - 成功したら access/refresh token を受け取り、TokenStore に保存する
- 続けて API の「プロフィール取得」エンドポイント(例:
/me)を呼び、UI に必要なクレーム情報を取得する - その情報で ClaimsPrincipal を作り、AuthenticationStateProvider に NotifyAuthenticationStateChanged() で通知する
- 以降の API 呼び出しは HttpClient が自動で Bearer access token を付ける(401 のとき refresh → リトライ)
ここまでを押さえると、Blazor 側の UI は自然にこう書けます。
<AuthorizeView>
<Authorized>
<p>こんにちは、@context.User.Identity?.Name さん</p>
</Authorized>
<NotAuthorized>
<p>ログインしてください</p>
</NotAuthorized>
</AuthorizeView>
Web API 側の前提:MapIdentityApi が Bearer トークンを発行できる状態にしておく
Blazor 側の話が主題ですが、API 側で「トークン発行」が成立していることが前提です。典型的には次のような形になります(プロジェクトのテンプレートや設定によって差はあります)。
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.Data;
var builder = WebApplication.CreateBuilder(args);
// Bearer トークン発行/検証(IdentityConstants.BearerScheme を使う構成例)
builder.Services.AddAuthentication()
.AddBearerToken(IdentityConstants.BearerScheme);
builder.Services.AddAuthorization();
// Identity API endpoints(ユーザー型は環境に合わせて)
builder.Services.AddIdentityApiEndpoints<IdentityUser>()
.AddEntityFrameworkStores<AppDbContext>();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGroup("/account")
.MapIdentityApi<IdentityUser>();
app.Run();
また、Blazor 側で表示名やロールを使いたい場合、プロフィールを返すエンドポイントがあると非常に楽です(後述の ClaimsPrincipal 生成に使います)。
(例)プロフィール取得の /me エンドポイント
MapIdentityApi が返すのは基本的にトークンなので、UI 用の情報が欲しい場合は API に “me” を足すのが実務では安定します。
using System.Security.Claims;
app.MapGet("/me", (ClaimsPrincipal user) =>
{
var name = user.Identity?.Name ?? "";
var roles = user.FindAll(ClaimTypes.Role).Select(r => r.Value).ToArray();
return Results.Ok(new
{
name,
displayName = name, // 実際はプロファイルテーブル等から取得
roles
});
}).RequireAuthorization();
Blazor Server 側:TokenStore を作る(トークンをクレームに入れない)
まずはトークンを持ち回るための入れ物を用意します。ここでの重要な方針は 「トークンを Claims に入れない」 です。Claims は UI で参照されやすく、ログ・例外・デバッグ表示などの経路で露出しやすいからです。
TokenSet(access/refresh と期限)
public sealed class TokenSet
{
public string AccessToken { get; init; } = "";
public string RefreshToken { get; init; } = "";
public DateTimeOffset ExpiresAt { get; init; }
public bool HasAccessToken => !string.IsNullOrWhiteSpace(AccessToken);
public bool IsExpiredSoon(TimeSpan threshold)
=> ExpiresAt <= DateTimeOffset.UtcNow.Add(threshold);
}
ITokenStore(Scoped サービス)
public interface ITokenStore
{
ValueTask<TokenSet?> GetAsync(CancellationToken ct = default);
ValueTask SetAsync(TokenSet tokens, CancellationToken ct = default);
ValueTask ClearAsync(CancellationToken ct = default);
}
まずはシンプルに:メモリ(circuit 内)に保持する実装
最小構成としては、Blazor Server の Scoped サービスに保持します(ただし後述のとおり、ページ更新や再接続で消えやすい点に注意)。
public sealed class MemoryTokenStore : ITokenStore
{
private TokenSet? _tokens;
public ValueTask<TokenSet?> GetAsync(CancellationToken ct = default)
=> ValueTask.FromResult(_tokens);
public ValueTask SetAsync(TokenSet tokens, CancellationToken ct = default)
{
_tokens = tokens;
return ValueTask.CompletedTask;
}
public ValueTask ClearAsync(CancellationToken ct = default)
{
_tokens = null;
return ValueTask.CompletedTask;
}
}
Blazor Server 側:AuthenticationStateProvider をカスタムして「ログイン済み」を通知する
次に Blazor の認証状態を作ります。Blazor の [Authorize] や <AuthorizeView> は、AuthenticationStateProvider が返す ClaimsPrincipal を見ています。つまり、API から token を受け取った後に、Blazor 側で「このユーザーでログイン済み」と分かる Principal を作ればよいです。
プロフィール DTO
public sealed class UserProfile
{
public string Name { get; init; } = "";
public string DisplayName { get; init; } = "";
public string[] Roles { get; init; } = Array.Empty<string>();
}
カスタム AuthenticationStateProvider
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
public sealed class ApiAuthStateProvider : AuthenticationStateProvider
{
private static readonly ClaimsPrincipal Anonymous =
new ClaimsPrincipal(new ClaimsIdentity());
private ClaimsPrincipal _current = Anonymous;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(new AuthenticationState(_current));
public void MarkAuthenticated(UserProfile profile)
{
var claims = new List<Claim>
{
new(ClaimTypes.Name, profile.Name),
new("display_name", profile.DisplayName),
};
foreach (var role in profile.Roles)
claims.Add(new Claim(ClaimTypes.Role, role));
var identity = new ClaimsIdentity(claims, authenticationType: "ApiToken");
_current = new ClaimsPrincipal(identity);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
public void MarkLoggedOut()
{
_current = Anonymous;
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
}
ここでは UI に必要なクレームだけを入れています。トークンは入れません。トークンは TokenStore に別管理します。
ログイン処理:login → token 保存 → /me でクレーム取得 → Notify
次にログイン画面での処理を組みます。ここでは “MapIdentityApi が返すトークン” を受け取り、それを TokenStore に格納し、続けて /me を呼んで UserProfile を得てから AuthenticationStateProvider に通知します。
(例)login のレスポンス型(API の返却に合わせて調整)
Identity API のレスポンスは構成で差がありますが、多くの場合は次のような要素を含みます。
{
"accessToken": "xxxxx",
"expiresIn": 3600,
"refreshToken": "yyyyy"
}
public sealed class LoginResponse
{
public string AccessToken { get; init; } = "";
public int ExpiresIn { get; init; } // seconds
public string RefreshToken { get; init; } = "";
}
(例)ログインコンポーネントの骨組み
@page "/login"
@using System.Net
@inject HttpClient Api
@inject ITokenStore TokenStore
@inject ApiAuthStateProvider AuthStateProvider
@inject NavigationManager Nav
ログイン
@if (!string.IsNullOrEmpty(ErrorMessage))
{
@ErrorMessage
}
ユーザー名
パスワード
@if (TwoFactorRequired)
{
2FA コード
}
送信
@code {
private LoginModel Model = new();
private string? ErrorMessage;
private bool TwoFactorRequired;
private async Task SubmitAsync()
{
ErrorMessage = null;
var payload = new
{
userName = Model.UserName,
password = Model.Password,
twoFactorCode = TwoFactorRequired ? Model.TwoFactorCode : null
};
var res = await Api.PostAsJsonAsync("/account/login", payload);
if (res.IsSuccessStatusCode)
{
var lr = await res.Content.ReadFromJsonAsync<LoginResponse>();
if (lr is null || string.IsNullOrWhiteSpace(lr.AccessToken))
{
ErrorMessage = "ログイン応答の解析に失敗しました。";
return;
}
var tokens = new TokenSet
{
AccessToken = lr.AccessToken,
RefreshToken = lr.RefreshToken,
ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(lr.ExpiresIn)
};
await TokenStore.SetAsync(tokens);
// ここで /me を呼び、UI用クレームを取得
Api.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokens.AccessToken);
var profile = await Api.GetFromJsonAsync<UserProfile>("/me");
if (profile is null)
{
ErrorMessage = "プロフィール取得に失敗しました。";
await TokenStore.ClearAsync();
return;
}
AuthStateProvider.MarkAuthenticated(profile);
Nav.NavigateTo("/");
return;
}
if (res.StatusCode == HttpStatusCode.Unauthorized)
{
// MapIdentityApi の 2FA 必須の返し方に合わせて判定ロジックを調整
// 例:ProblemDetails の title/type/extension を見て TwoFactorRequired を立てる
TwoFactorRequired = true;
ErrorMessage = "2FA が必要です。認証コードを入力してください。";
return;
}
ErrorMessage = $"ログインに失敗しました。({(int)res.StatusCode})";
}
private sealed class LoginModel
{
[System.ComponentModel.DataAnnotations.Required]
public string UserName { get; set; } = "";
[System.ComponentModel.DataAnnotations.Required]
public string Password { get; set; } = "";
public string? TwoFactorCode { get; set; }
}
}
ここでは説明を分かりやすくするために Api.DefaultRequestHeaders.Authorization を直接セットしていますが、実務では後述の DelegatingHandler を使って自動付与に寄せるほうが保守しやすいです。
API 呼び出しに Bearer を付与する:HttpClient + DelegatingHandler が安定
Blazor Server では、コンポーネント内の HttpClient 呼び出しは基本的にサーバーから行われます。トークン付与を「呼び出し箇所ごと」に書くと漏れます。HttpClientFactory + DelegatingHandler で一箇所に集約するのが定石です。
Bearer 付与用ハンドラ
using System.Net;
using System.Net.Http.Headers;
public sealed class BearerTokenHandler : DelegatingHandler
{
private readonly ITokenStore _tokenStore;
public BearerTokenHandler(ITokenStore tokenStore)
=> _tokenStore = tokenStore;
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var tokens = await _tokenStore.GetAsync(cancellationToken);
if (tokens is not null && !string.IsNullOrWhiteSpace(tokens.AccessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
}
return await base.SendAsync(request, cancellationToken);
}
}
401 時に refresh → リトライするハンドラ(実務向け)
access token は期限が短いので、401 で都度ログイン画面に戻すと UX が崩れます。refresh token がある前提なら、401 → refresh → 1回だけリトライの仕組みが現実的です。
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
public sealed class RefreshingBearerHandler : DelegatingHandler
{
private readonly ITokenStore _tokenStore;
private readonly HttpClient _authClient; // refresh 専用(循環参照を避ける)
private readonly SemaphoreSlim _refreshLock = new(1, 1);
public RefreshingBearerHandler(ITokenStore tokenStore, IHttpClientFactory factory)
{
_tokenStore = tokenStore;
_authClient = factory.CreateClient("Auth"); // refresh はこのクライアントで
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var tokens = await _tokenStore.GetAsync(cancellationToken);
if (tokens is not null && !string.IsNullOrWhiteSpace(tokens.AccessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
}
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode != HttpStatusCode.Unauthorized)
return response;
// 401 なら refresh を試みる
if (tokens is null || string.IsNullOrWhiteSpace(tokens.RefreshToken))
return response;
await _refreshLock.WaitAsync(cancellationToken);
try
{
// 他スレッドが既に更新しているかもしれないので再取得
var latest = await _tokenStore.GetAsync(cancellationToken);
if (latest is not null && latest.HasAccessToken && !latest.IsExpiredSoon(TimeSpan.FromMinutes(1)))
{
// 既に新しい token がありそうなら、そのまま1回だけリトライ
response.Dispose();
var retry = CloneRequest(request);
retry.Headers.Authorization = new AuthenticationHeaderValue("Bearer", latest.AccessToken);
return await base.SendAsync(retry, cancellationToken);
}
var refreshPayload = new
{
refreshToken = tokens.RefreshToken
// 実際の API 契約により accessToken も必要な場合は追加
};
var refreshRes = await _authClient.PostAsJsonAsync("/account/refresh", refreshPayload, cancellationToken);
if (!refreshRes.IsSuccessStatusCode)
return response;
var lr = await refreshRes.Content.ReadFromJsonAsync<LoginResponse>(cancellationToken: cancellationToken);
if (lr is null || string.IsNullOrWhiteSpace(lr.AccessToken))
return response;
var newTokens = new TokenSet
{
AccessToken = lr.AccessToken,
RefreshToken = lr.RefreshToken,
ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(lr.ExpiresIn)
};
await _tokenStore.SetAsync(newTokens, cancellationToken);
// もとのリクエストを1回だけリトライ
response.Dispose();
var retry2 = CloneRequest(request);
retry2.Headers.Authorization = new AuthenticationHeaderValue("Bearer", newTokens.AccessToken);
return await base.SendAsync(retry2, cancellationToken);
}
finally
{
_refreshLock.Release();
}
}
private static HttpRequestMessage CloneRequest(HttpRequestMessage request)
{
// シンプルな GET/POST を想定したクローン例(必要なら拡張)
var clone = new HttpRequestMessage(request.Method, request.RequestUri);
foreach (var header in request.Headers)
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
if (request.Content is not null)
{
var ms = new MemoryStream();
request.Content.CopyToAsync(ms).GetAwaiter().GetResult();
ms.Position = 0;
clone.Content = new StreamContent(ms);
foreach (var header in request.Content.Headers)
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
return clone;
}
}
refresh は多重に走ると不整合になりやすいので、SemaphoreSlim で直列化しておくと安定します。
DI 登録例(Blazor Server 側)
using Microsoft.AspNetCore.Components.Authorization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// TokenStore と AuthStateProvider
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped(sp => sp.GetRequiredService());
builder.Services.AddAuthorizationCore();
// API クライアント
builder.Services.AddTransient();
builder.Services.AddHttpClient("Api", client =>
{
client.BaseAddress = new Uri("[https://api.example.com](https://api.example.com)");
}).AddHttpMessageHandler();
// refresh 用(トークン付与しない/循環を避ける)
builder.Services.AddHttpClient("Auth", client =>
{
client.BaseAddress = new Uri("[https://api.example.com](https://api.example.com)");
});
// どこでも使う既定 HttpClient を "Api" に寄せたい場合
builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("Api"));
var app = builder.Build();
app.MapRazorComponents()
.AddInteractiveServerRenderMode();
app.Run();
さらに、ルートレベルで認証状態を流すために、レイアウト側で <CascadingAuthenticationState> を使う構成になっていることも確認してください(テンプレートによっては既に入っています)。
「Scoped に保存」だけだと消える:Blazor Server の circuit と永続化の現実
ここまでの実装は動きますが、運用で必ず出る落とし穴があります。それが ページ更新・再接続で circuit が変わると Scoped サービスの状態が消える という問題です。
つまり、MemoryTokenStore だけだと次の現象が起こりやすくなります。
- F5 更新で「突然ログアウトしたように見える」
- 一時的な切断からの再接続でログイン状態が消える
- 複数タブ運用で状態が一致しない
そこで、ログイン状態をもう少し長く維持したい場合は、TokenStore の背後に 永続化の仕組みを足すのが現実的です。
トークンの置き場所比較(よく使う選択肢)
| 保管方法 | 持続性 | メリット | デメリット/注意 | 向いているケース |
|---|---|---|---|---|
| Scoped(メモリ) | 弱い(circuit 依存) | 実装が最短、漏えいリスクが低い | 更新・再接続で消える | 社内ツール、まず動かす、ログイン維持が要らない |
| ProtectedSessionStorage | 中(タブ/セッション単位) | ブラウザ保存でも暗号化され、更新に強い | タブを閉じると消える(挙動はブラウザ依存)、多インスタンス時は鍵共有が必要 | 短時間のログイン維持、端末共有が多い環境 |
| ProtectedLocalStorage | 強い(ブラウザに残る) | 再起動後も残せる | 端末共有・盗難のリスクが上がる(暗号化されても運用方針が必要) | 個人端末前提、利便性を強く優先 |
| サーバー側セッション/分散キャッシュ(Redis 等) | 強い | ブラウザにトークンを置かずに済む(BFF 風)、スケールにも対応しやすい | 実装が増える、セッション管理が必要 | 本番運用、セキュリティ優先、複数台構成 |
ProtectedBrowserStorage を使う場合の実務ポイントとして、次の2点は押さえておくと事故が減ります。
- スケールアウトするなら Data Protection のキー共有が必須(共有しないと別インスタンスで復号できず、再接続時に不整合になりやすい)
- 永続化するのは「できるだけ必要最小限」にし、refresh token の寿命や失効(ローテーション)も考慮する
2FA の悩みどころ:資格情報の一時保持をどうするか
MapIdentityApi の 2FA は、一般的に次の流れになりやすいです。
- password だけで login → 401(TwoFactorRequired)
- 2FA コードを付けて再度 login → 成功(トークン発行)
このとき UI が迷うのが「2FA 画面を出すまで、username/password をどこに置くか」です。結論としては、hidden フィールド等でブラウザに埋め込むのは避けるのが安全側です(XSS や意図しない送信、ブラウザの自動補完・復元など、露出経路が増えます)。
現実的な設計パターン
| パターン | 安全性 | UX | 実装コスト | 要点 |
|---|---|---|---|---|
| 2FA 時にパスワードを再入力させる | 高い | やや悪い | 低い | 資格情報を保持しないので単純で堅い |
| Blazor Server のサーバー側メモリに短時間だけ保持 | 中〜高 | 良い | 中 | 露出を最小化(ログ・永続化禁止、タイムアウト・即時クリア) |
| API を拡張して「challengeId + 2FA」で完結させる | 高い | 良い | 高い | 2FA の2回目に password を不要にできる(設計が変わる) |
| BFF(セッションID)方式でトークンも資格情報もブラウザに持たせない | 高い | 良い | 中〜高 | ブラウザはセッションだけ、トークンはサーバー保管 |
「短時間だけ保持」する場合の安全寄り実装のコツ
Blazor Server は基本的にサーバー上で状態を持てるので、2FA のためにどうしても password を保持するなら、次の条件を徹底するとリスクを下げられます。
- 保持はサーバー側のみ(hidden フィールドや localStorage に置かない)
- 保持期間に上限を設ける(例:2〜3分)
- 成功/失敗に関わらず即時クリア
- 例外ログやトレースに password が出ないようにする(モデルの ToString/ログ出力に注意)
たとえば 2FA への「遷移中」だけ保持するための入れ物を Scoped で作り、タイムアウトを持たせます。
public sealed class TwoFactorTempCredentials
{
public string UserName { get; private set; } = "";
public string Password { get; private set; } = "";
public DateTimeOffset CreatedAt { get; private set; }
public void Set(string userName, string password)
{
UserName = userName;
Password = password;
CreatedAt = DateTimeOffset.UtcNow;
}
public bool IsExpired(TimeSpan ttl)
=> CreatedAt == default || DateTimeOffset.UtcNow - CreatedAt > ttl;
public void Clear()
{
UserName = "";
Password = "";
CreatedAt = default;
}
}
ログインコンポーネント側では、401(2FA 必須)を受けた時点で一時領域にセットし、2FA 成功時に必ずクリアします。どうしても不安が強い場合は、前述の通り 再入力方式に寄せるのが堅実です。
さらに安全寄りにするなら:Blazor Server を BFF 的に扱い「ブラウザにトークンを置かない」
「refresh token をブラウザに保存したくない」「端末紛失や共有が心配」「XSS の被害を最小化したい」といった要求がある場合、Blazor Server は構造的に BFF(Backend for Frontend) を作りやすいです。
BFF 的にする場合の考え方はシンプルです。
- ブラウザが持つのは セッションID(Cookie)だけ
- access/refresh token は サーバー側(Redis 等) に置く
- Blazor Server はセッションIDから token を取り出し、サーバー間通信で API を呼ぶ
この方式のメリットは、ブラウザから token が消えることです。結果として、localStorage や sessionStorage に token を置く運用よりも、管理対象が絞られます。特に複数台構成では、Redis などの分散ストアに token を置くと、circuit の再作成や再接続でも整合性が取りやすくなります。
一方で、セッション管理(失効・延長・多重ログイン・ログアウト時の掃除)が必要になるため、要件に合わせて採用を検討してください。
HttpContext.SignInAsync(Cookie サインイン)をどう扱うか
Blazor Server は SignalR の circuit を介して UI が動くため、「API の Bearer 認証」をそのまま「Blazor の Cookie 認証」に寄せてしまうと、設計が混線しやすくなります。
- 「Blazor 側のログイン維持」は Cookie が得意
- 「API 呼び出しの認可」は Bearer token が得意
ここを無理に一体化せず、まずは本記事の方針どおり AuthenticationStateProvider で UI を成立させ、API 呼び出しは TokenStore と HttpClient で責務分離すると、実装も運用も見通しが良くなります。Cookie 方式を採用するにしても、それは「UI のセッションのため」と割り切り、API の token と混ぜないのがコツです。
運用で効くチェックリスト
- トークンを Claims に入れない(ログ・例外・画面表示の露出経路が増える)
- refresh の同時実行を抑止する(SemaphoreSlim 等)
- 401 を見たら refresh → 1回リトライ、それでもダメならログアウト誘導
- TokenStore の永続化をするなら、Data Protection キー共有や 分散キャッシュを設計に含める
- 2FA の資格情報保持は「保持しない」か「サーバー側に短時間だけ」へ(hidden フィールドは避ける)
- ログに request payload をそのまま出さない(password や 2FA コードの混入を防ぐ)
よくあるトラブルと切り分け
<AuthorizeView> がずっと NotAuthorized になる
- AuthenticationStateProvider の差し替えができているか確認(DI 登録)
- ルート/レイアウトで <CascadingAuthenticationState> が適用されているか確認
- ログイン成功後に NotifyAuthenticationStateChanged() を呼べているか確認
ログイン直後は OK なのに、ページ更新で未ログインになる
- MemoryTokenStore(Scoped)だけで保持していると起こりやすい
- ログイン維持が必要なら、ProtectedSessionStorage / ProtectedLocalStorage / サーバー側ストアを検討
API が 401 になる(ログイン済みに見えるのに)
- Blazor の認証状態は UI の表示制御であり、API の Authorization ヘッダとは別
- DelegatingHandler が付いているか、正しい HttpClient を使っているか(名前付き/型付きクライアント)を確認
2FA の判定がうまくできない
- MapIdentityApi が返す 401 の中身(ProblemDetails の title/type/extensions など)を一度確認し、TwoFactorRequired の判定条件を固める
- 要件が厳しい場合は、2FA フローを API 側で拡張して challengeId を導入するのも手
独立した Web API(MapIdentityApi)と Blazor Server を組み合わせる場合、最初に責務分離さえ押さえれば、実装は一気に安定します。UI は AuthenticationStateProvider で「ログイン済み」を表現し、API には TokenStore の token を使って呼び出す。この整理ができると、2FA や refresh を追加しても破綻しにくくなります。

コメント