IdentityServerなしで自作IdPとBlazor OIDC認証を実装する完全ガイド

IdentityServer などの既製 IdP を使わずに、自作の認証サーバーと Blazor OIDC クライアントを組み合わせると、「ログインしたのに戻ってこない」「IDX10511」「NullReference」で詰まりがちです。この記事では、その原因と正しい実装手順をサンプルコード付きで整理します。

目次

自作 IdP + Blazor OIDC でよく起こるトラブル

まず、質問として挙がりやすい症状を整理しておきます。

症状表面上の挙動想定される原因の方向性
ログイン後にクライアントへ戻らないログイン画面は出るが、ログイン成功後も認証が完了しない/authorize の「コード発行」と 302 リダイレクトの実装不足
IDX10511: Signature validation failedBlazor 側でサインイン処理中に例外が発生ID トークンの署名鍵と JWKS の不整合、issuer/audience 不一致
NullReferenceExceptionサインインコールバックで突然 NRE が出るid_token が返っていない、もしくはメタデータ取得に失敗している

一見バラバラな問題に見えますが、多くは以下のどれかに集約されます。

  • 認可コードフローの「往復」を最後まで実装しきれていない
  • ID トークンの署名・公開鍵・issuer/audience/nonce がバラバラ
  • scope に openid を含めておらず、id_token すら返していない
  • PKCE の検証や redirect_uri、CallbackPath の取り扱いが不正

結論:認可コードフローを “最後まで” 実装する

自作 IdP で最初に押さえるべきポイントは、「認可コードフローの往復」をフルで実装することです。概略フローは次のようになります。

ステップ送信先主なパラメータやるべきこと
1ブラウザ → IdP /authorizeresponse_type=code
client_id, redirect_uri
scope=openid …
state, nonce
code_challenge, code_challenge_method
クライアントと redirect_uri を検証し、ユーザーをログイン画面へリダイレクト
2ブラウザ → IdP /account/loginユーザー名・パスワードなど認証に成功したら、事前に組み立てておいたポストログイン URL へ遷移
3IdP /authorize/postloginclient_id, redirect_uri, scope, state, nonce, code_challenge…認可コードを発行して保存し、redirect_uri?code=…&state=… に 302 で返す
4クライアント → IdP /tokengrant_type=authorization_code
code, redirect_uri, client_id
code_verifier
コードと PKCE を検証し、id_token と access_token を発行して返す
5クライアント内部id_token, access_token署名・iss・aud・nonce を検証し、サインイン完了

この「1→2→3→4→5」のどこかが欠けていると、Blazor OIDC クライアントは必ずエラーやハングに見える挙動になります。

/authorize とログインフローを正しく分割する

IdP 側でありがちな失敗は、/authorize とログイン画面を一体で作ってしまい、ログイン画面からクライアントの redirect_uri に戻さないケースです。これを避けるために、認可エンドポイントとポストログインをきちんと分割します。

/authorize の実装例

まずは GET /authorize の例です。

[HttpGet("authorize")]
public IActionResult Authorize(
    [FromQuery] string client_id,
    [FromQuery] string redirect_uri,
    [FromQuery] string state,
    [FromQuery] string scope,
    [FromQuery] string code_challenge,
    [FromQuery] string code_challenge_method = "S256",
    [FromQuery] string nonce = null)
{
    ValidateClientAndRedirectUri(client_id, redirect_uri);

    // ログイン成功後に戻ってくる先(ポストログイン)を組み立てる
    var returnUrl = $"/authorize/postlogin?client_id={Url.Encode(client_id)}" +
                    $"&redirect_uri={Url.Encode(redirect_uri)}" +
                    $"&state={Url.Encode(state)}" +
                    $"&scope={Url.Encode(scope)}" +
                    $"&code_challenge={Url.Encode(code_challenge)}" +
                    $"&code_challenge_method={Url.Encode(code_challenge_method)}" +
                    (nonce != null ? $"&nonce={Url.Encode(nonce)}" : "");

    return Redirect($"/account/login?returnUrl={Url.Encode(returnUrl)}");
}

ここで重要なのは次の点です。

  • client_id と redirect_uri をサーバー側の登録情報で必ず検証する
  • ログイン成功後に必要になる情報(state, scope, code_challenge, nonce 等)をすべて returnUrl に詰めておく
  • ログイン画面には 一度だけ遷移させ、成功時は必ず /authorize/postlogin を経由する

ログイン成功後のポストログイン

ログイン成功後だけ到達する /authorize/postlogin では、認可コードの発行と redirect_uri への 302 が主な仕事です。

[Authorize]
[HttpGet("authorize/postlogin")]
public IActionResult PostLogin(
    [FromQuery] string client_id,
    [FromQuery] string redirect_uri,
    [FromQuery] string state,
    [FromQuery] string scope,
    [FromQuery] string code_challenge,
    [FromQuery] string code_challenge_method,
    [FromQuery] string nonce = null)
{
    // 1 回限りの認可コードを発行し、ユーザーや PKCE 情報と紐づけて保存
    var code = IssueAuthorizationCode(new AuthorizationCodeRecord
    {
        UserId = User.GetUserId(),
        ClientId = client_id,
        RedirectUri = redirect_uri,
        Scope = scope,
        CodeChallenge = code_challenge,
        CodeChallengeMethod = code_challenge_method,
        Nonce = nonce,
        ExpiresAtUtc = DateTime.UtcNow.AddMinutes(5)
    });

    // クライアントの redirect_uri へ code と state を 302 で返す
    var sep = redirect_uri.Contains("?") ? "&" : "?";
    return Redirect($"{redirect_uri}{sep}code={Url.Encode(code)}&state={Url.Encode(state)}");
}

ここが実装されていないと、Blazor 側の /signin-oidc(CallbackPath)には一生到達しません。

/token エンドポイントでコードと PKCE を検証する

次に、/token エンドポイントです。Blazor からは バックチャンネルで HTTP POST が飛んできます。

リクエストパラメータ

パラメータ名必須内容
grant_type必須authorization_code
code必須/authorize から返した認可コード
redirect_uri必須/authorize 時と完全一致していること
client_id必須クライアント ID
code_verifier必須PKCE の検証用文字列

/token の実装例

public class TokenRequest
{
    public string grant_type { get; set; }
    public string code { get; set; }
    public string redirect_uri { get; set; }
    public string client_id { get; set; }
    public string code_verifier { get; set; }
}

[HttpPost("token")]
public IActionResult Token([FromForm] TokenRequest req)
{
    // grant_type をチェック
    if (!string.Equals(req.grant_type, "authorization_code",
                       StringComparison.OrdinalIgnoreCase))
    {
        return BadRequest(new { error = "unsupported_grant_type" });
    }

    // 認可コードを検索 & 検証
    var record = ValidateAuthorizationCode(req.code, req.client_id, req.redirect_uri);
    if (record == null)
    {
        return BadRequest(new { error = "invalid_grant" });
    }

    // PKCE 検証(S256 前提)
    VerifyPkce(record.CodeChallengeMethod, record.CodeChallenge, req.code_verifier);

    // ID トークン作成
    var idToken = CreateIdToken(new IdTokenDescriptor
    {
        Issuer = IssuerUrl,             // 例: https://localhost:7098
        Audience = record.ClientId,     // クライアント ID
        Subject = record.UserId,
        Nonce = record.Nonce,
        Expires = DateTime.UtcNow.AddMinutes(5),
        SigningKey = CurrentRsaSecurityKey,
        Kid = CurrentKeyId
    });

    // 必要ならスコープに応じてクレームを追加
    var accessToken = CreateAccessToken(record);

    return Ok(new
    {
        access_token = accessToken,
        id_token = idToken,             // これが無いと Blazor 側で NRE の原因に
        token_type = "Bearer",
        expires_in = 3600
    });
}

id_token を返し忘れると、OpenIdConnect ハンドラ内で必要な値が取得できず、最終的に NullReferenceException に繋がりやすいので要注意です。

ID トークン署名鍵と JWKS を「永続化」する

IDX10511(署名検証エラー)の多くは、「IdP が署名に使った秘密鍵」と「クライアントが JWKS から取得した公開鍵」がペアになっていないことから発生します。

よくある落とし穴

パターン具体的な状況結果
毎回 RSA 鍵を生成アプリ起動時に RSA.Create() で毎回新しい鍵を作っているトークン発行時と JWKS 参照時でキーが変わり、署名検証に失敗
kid は同じだが中身が違うkid を固定文字列にしつつ、鍵ファイルを更新してしまったクライアントは kid に基づき鍵を選ぶが、中身が一致せず検証失敗
alg の不一致ID トークンを HS256 で署名しているのに、メタデータでは RS256 を宣言クライアント側が期待するアルゴリズムと異なりエラー

鍵の読み込みと JWKS 公開例

鍵は一度作ったら、PEM ファイルなどに保存し、アプリ起動時に読み込むようにします。

// 起動時(Program.cs など)
// rsa はどこからでも参照できるようにしておく
var rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText("keys/signing-private.pem"));

CurrentRsaSecurityKey = new RsaSecurityKey(rsa)
{
    KeyId = "kid-2025-03"
};
CurrentKeyId = "kid-2025-03";

公開鍵は /.well-known/jwks.json で公開します。

[HttpGet("/.well-known/jwks.json")]
public IActionResult Jwks()
{
    var p = rsa.ExportParameters(false);
    return Ok(new
    {
        keys = new[]
        {
            new
            {
                kty = "RSA",
                kid = CurrentKeyId,
                n = Base64UrlEncoder.Encode(p.Modulus),
                e = Base64UrlEncoder.Encode(p.Exponent)
            }
        }
    });
}

Blazor 側はこの jwks_uri を通じて公開鍵を取得し、ID トークンのヘッダに入った kid と照合して署名検証を行います。

OIDC Discovery (openid-configuration) を用意する

Blazor の OpenIdConnect ハンドラは、Authority から /.well-known/openid-configuration を自動的に取得し、そこに書かれたエンドポイントや設定を使います。このため、Discovery ドキュメントの内容と実際のエンドポイントがずれていると、メタデータ取得に失敗 → NRE というパターンが発生します。

最低限含めるべき項目

項目例ポイント
issuerhttps://localhost:7098Authority と完全一致させる(末尾スラッシュにも注意)
authorization_endpointhttps://localhost:7098/authorize/authorize のフル URL
token_endpointhttps://localhost:7098/token/token のフル URL
jwks_urihttps://localhost:7098/.well-known/jwks.json公開鍵を返すエンドポイント
id_token_signing_alg_values_supported[ “RS256” ]サポートする署名アルゴリズム

ここで宣言した issuer と、ID トークンの iss、Blazor の options.Authority は必ず同じにしてください。

Blazor OIDC クライアント側の設定

IdP 側が正しく実装できても、クライアント(Blazor)の設定がずれていると認証は完了しません。最低限、次のような設定を押さえます。

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
    options.Authority = "https://localhost:7098"; // issuer と完全一致
    options.ClientId = "blazor-client";
    options.ResponseType = OpenIdConnectResponseType.Code; // 認可コードフロー
    options.UsePkce = true; // 無効化で逃げず、正しく実装する
    options.SaveTokens = true;
    options.CallbackPath = "/signin-oidc"; // 既定で OK

    options.Scope.Clear();
    options.Scope.Add("openid");  // <= これが無いと id_token が返らない
    options.Scope.Add("profile");
    options.Scope.Add("email");

    // 開発時に http を使うならだけ false
    // options.RequireHttpsMetadata = false;
});

Scope.Add("openid") を忘れると、IdP 側が OIDC を実装していても、Blazor 側は「OpenID Connect 認証」と認識できません。その結果、id_token を前提とした処理で NRE に繋がります。

IDX10511(署名エラー)を潰し込むポイント

IDX10511: Signature validation failed が発生した場合のチェックポイントを表にまとめます。

チェック項目確認内容具体的な修正例
kid の一致ID トークンヘッダの kid と JWKS の kid が一致しているかトークン作成時の SecurityKey.KeyId と JWKS の kid を同じ固定値にする
鍵ペアの対応JWKS の n, e が署名に使った秘密鍵と対の公開鍵か同じ PEM ファイルから秘密鍵と公開鍵をエクスポートし、それをもとに JWKS を生成
alg の一致ID トークンヘッダの alg が RS256 になっているかSecurityTokenDescriptor などで SigningCredentials に RS256 を指定
issuer の一致ID トークンの iss と Authority/OpenID メタデータの issuer が一致しているか末尾スラッシュやポート番号の違いを無くし、完全一致させる
audience の一致ID トークンの aud がクライアント ID と一致しているかトークン作成時に Audience を "blazor-client" など実際の ClientId に設定
鍵の永続化アプリ再起動で鍵が変わっていないかRSA 鍵ペアをファイル・キーストアなどに保存し、起動時に読み込む方式へ変更

NullReferenceException の典型パターン

署名エラーのように分かりやすいメッセージが出ず、NullReferenceException だけが飛んでくる場合は、次のような原因が多いです。

現象よくある原因対処
サインインコールバックで NREトークン応答に id_token が含まれていない/token 応答に必ず id_token を含める。scope に openid を含める
メタデータ取得時に NRE/.well-known/openid-configuration へのアクセスが 404 などAuthority の URL を見直し、Discovery ドキュメントを正しいパスに配置
CallbackPath で NREIdP 側の redirect_uri と Blazor 側の CallbackPath が不一致IdP に登録した redirect_uri を https://client-app/signin-oidc など、CallbackPath と統一

特に、id_token が返ってきていない場合はログに直接出ず、後続処理で NRE になるので、/token の JSON を一度ログに出して目視確認するのがおすすめです。

PKCE を無効化せず、正しく実装する

「PKCE を無効にしたらなぜか動いた」というケースがありますが、これは本質的な問題の隠蔽に過ぎません。実運用を考えると PKCE は必須と考えてよく、UsePkce を true のまま、IdP 側の検証を実装するべきです。

PKCE 検証の流れ

  1. クライアント(Blazor)がランダムな code_verifier を生成
  2. その SHA256 を Base64URL エンコードしたものが code_challenge
  3. /authorize には code_challenge と code_challenge_method = S256 を送る
  4. /token には元の code_verifier を送る
  5. IdP 側は code_verifier をもとに再度 challenge を計算し、保存しておいた CodeChallenge と一致するか検証

検証コードは例えば次のように書けます。

private void VerifyPkce(string method, string expectedChallenge, string codeVerifier)
{
    if (string.IsNullOrEmpty(expectedChallenge))
    {
        throw new InvalidOperationException("PKCE is required.");
    }

    if (!string.Equals(method, "S256", StringComparison.OrdinalIgnoreCase))
    {
        throw new InvalidOperationException("Only S256 is supported.");
    }

    using var sha = SHA256.Create();
    var bytes = Encoding.ASCII.GetBytes(codeVerifier);
    var hash = sha.ComputeHash(bytes);
    var actualChallenge = Base64UrlEncoder.Encode(hash);

    if (!CryptographicOperations.FixedTimeEquals(
            Encoding.ASCII.GetBytes(actualChallenge),
            Encoding.ASCII.GetBytes(expectedChallenge)))
    {
        throw new SecurityTokenException("Invalid code_verifier.");
    }
}

PKCE をしっかり実装しておけば、公開クライアントからの認可コード窃取リスクを大きく下げることができます。

動作確認チェックリスト

最後に、自作 IdP + Blazor OIDC の組み合わせを確認するためのチェックリストをまとめます。

/authorize リクエスト

  • response_type=code が指定されている
  • client_id が IdP に登録済みの値と一致している
  • redirect_uri が登録情報と完全一致(クエリ含めて)
  • scope に openid が含まれている
  • state が十分ランダムな値で付与されている
  • nonce が付与されており、ID トークンにそのまま入っている
  • code_challenge と code_challenge_method=S256 が指定されている

ログイン後の 302 リダイレクト

  • ブラウザのネットワークトレースで、ログイン成功後に redirect_uri?code=...&state=... へ 302 が飛んでいるか
  • state の値が /authorize 時の値と一致しているか

/token リクエストと応答

  • リクエストボディに grant_type=authorization_code が含まれている
  • /authorize で発行した code をそのまま送っているか
  • redirect_uri と client_id が認可コードのレコードと一致しているか
  • code_verifier に対して PKCE の検証が成功しているか
  • 応答 JSON に id_token、access_token、token_type が含まれているか

ID トークンとメタデータ

  • ID トークンのヘッダ alg=RS256、kid が JWKS のキーと一致している
  • ペイロードの iss が Discovery ドキュメントと一致している
  • aud がクライアント ID と一致している
  • nonce が /authorize で送った値と一致している
  • exp が現在時刻より後になっている

鍵とエンドポイント

  • アプリ再起動後も /.well-known/jwks.json の n, e, kid が変わっていない
  • Discovery ドキュメントの authorization_endpoint と実際の /authorize が一致
  • token_endpoint と /token、jwks_uri と /.well-known/jwks.json が一致

自作 IdP を安定運用するための設計のコツ

最後に、IdentityServer などを使わずに自作 IdP を作るときに意識しておくと良い設計ポイントをいくつか挙げておきます。

認可コードとトークンのライフサイクルを明確にする

  • 認可コードは短寿命(数分以内)かつ一度きりの利用に限定する
  • トークン発行後はコードを即座に失効状態にして再利用を防ぐ
  • テーブルには「発行日時」「失効日時」「使用済みフラグ」を必ず持たせる

ログを詳細に残す

  • /authorize, /token, /account/login それぞれで、入力パラメータと結果をログに(秘密情報を除く)
  • ID トークンの iss, aud, nonce, kid などを、検証前後でログに出す
  • 例外発生時にはスタックトレースだけでなく、関連する client_id, redirect_uri も出しておく

テスト用クライアントを用意する

Blazor クライアントだけでなく、Postman や簡易コンソールアプリなどのテストクライアントを用意しておくと、問題の切り分けが非常に楽になります。

  • まずはテストクライアントで /authorize → /token が正常に流れるか確認
  • その上で Blazor クライアントを接続し、差分を見ていく

まとめ

IdentityServer を使わずに自作 IdP を構築し、Blazor OIDC クライアントと連携させる場合、つまずきやすいポイントはどれも仕様の「穴埋め」不足です。

  • /authorize → ログイン → /authorize/postlogin → /token という認可コードフローを最後まで作り切る
  • ID トークンの署名鍵と JWKS を固定し、iss/aud/nonce/kid/alg の整合性を保つ
  • PKCE は無効化せず、code_challenge と code_verifier をきちんと検証する
  • scope に openid を含め、必ず id_token を返す
  • Discovery ドキュメントと Authority を完全一致させ、Blazor から正しくメタデータを取得させる

この記事で紹介した実装パターンとチェックリストに沿って見直せば、「ログイン後に戻ってこない」「IDX10511 で止まる」「なぜか NRE」といった問題は一つずつ解消できるはずです。自作 IdP を堅牢に作り込み、Blazor アプリの認証基盤を自分のコントロール下に置きましょう。

この記事を書いた人

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

コメント

コメントする

目次