.NET 8 Blazor Server で MapIdentityApi 認証を実装する方法(JWT/RefreshToken/2FA 対応)

.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 tokenTokenStore(Scoped)+必要に応じて ProtectedBrowserStorage やサーバー側ストアlocalStorage に生の refresh token を置く/ログに出す/複数タブで競合

ポイントは次の2つです。

  • Blazor の認証状態は AuthenticationStateProvider で「ログイン済み」を通知する(トークンの有無とは別に成立させる)
  • API の呼び出しは TokenStore の access token を Authorization ヘッダに付ける(必要なら refresh で更新する)

全体像:Blazor Server を「UI と中継役」として組み立てる

実装の流れを、あえて単純化して描くと次のようになります。

  1. ログイン画面(Blazor Server)から Web API の /account/login に username/password を送る
  2. 成功したら access/refresh token を受け取り、TokenStore に保存する
  3. 続けて API の「プロフィール取得」エンドポイント(例:/me)を呼び、UI に必要なクレーム情報を取得する
  4. その情報で ClaimsPrincipal を作り、AuthenticationStateProvider に NotifyAuthenticationStateChanged() で通知する
  5. 以降の 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 を追加しても破綻しにくくなります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次