IdentityServer などの既製 IdP を使わずに、自作の認証サーバーと Blazor OIDC クライアントを組み合わせると、「ログインしたのに戻ってこない」「IDX10511」「NullReference」で詰まりがちです。この記事では、その原因と正しい実装手順をサンプルコード付きで整理します。
自作 IdP + Blazor OIDC でよく起こるトラブル
まず、質問として挙がりやすい症状を整理しておきます。
| 症状 | 表面上の挙動 | 想定される原因の方向性 |
|---|---|---|
| ログイン後にクライアントへ戻らない | ログイン画面は出るが、ログイン成功後も認証が完了しない | /authorize の「コード発行」と 302 リダイレクトの実装不足 |
| IDX10511: Signature validation failed | Blazor 側でサインイン処理中に例外が発生 | ID トークンの署名鍵と JWKS の不整合、issuer/audience 不一致 |
| NullReferenceException | サインインコールバックで突然 NRE が出る | id_token が返っていない、もしくはメタデータ取得に失敗している |
一見バラバラな問題に見えますが、多くは以下のどれかに集約されます。
- 認可コードフローの「往復」を最後まで実装しきれていない
- ID トークンの署名・公開鍵・issuer/audience/nonce がバラバラ
- scope に
openidを含めておらず、id_token すら返していない - PKCE の検証や redirect_uri、CallbackPath の取り扱いが不正
結論:認可コードフローを “最後まで” 実装する
自作 IdP で最初に押さえるべきポイントは、「認可コードフローの往復」をフルで実装することです。概略フローは次のようになります。
| ステップ | 送信先 | 主なパラメータ | やるべきこと |
|---|---|---|---|
| 1 | ブラウザ → IdP /authorize | response_type=code client_id, redirect_uri scope=openid … state, nonce code_challenge, code_challenge_method | クライアントと redirect_uri を検証し、ユーザーをログイン画面へリダイレクト |
| 2 | ブラウザ → IdP /account/login | ユーザー名・パスワードなど | 認証に成功したら、事前に組み立てておいたポストログイン URL へ遷移 |
| 3 | IdP /authorize/postlogin | client_id, redirect_uri, scope, state, nonce, code_challenge… | 認可コードを発行して保存し、redirect_uri?code=…&state=… に 302 で返す |
| 4 | クライアント → IdP /token | grant_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 というパターンが発生します。
最低限含めるべき項目
| 項目 | 例 | ポイント |
|---|---|---|
| issuer | https://localhost:7098 | Authority と完全一致させる(末尾スラッシュにも注意) |
| authorization_endpoint | https://localhost:7098/authorize | /authorize のフル URL |
| token_endpoint | https://localhost:7098/token | /token のフル URL |
| jwks_uri | https://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 で NRE | IdP 側の 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 検証の流れ
- クライアント(Blazor)がランダムな
code_verifierを生成 - その SHA256 を Base64URL エンコードしたものが
code_challenge - /authorize には
code_challengeとcode_challenge_method = S256を送る - /token には元の
code_verifierを送る - 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 アプリの認証基盤を自分のコントロール下に置きましょう。

コメント