Microsoft Entra External ID(旧 Azure AD B2C)のシームレス移行では、未移行ユーザーだけレガシーAPIへメール+パスワードをPOSTして検証し、成功したらB2C側のパスワードを書き換えてログインさせる、という設計がよく採られます。本記事では「Webhook上は正しいのにB2Cだけ失敗する」典型原因と修正方法を、カスタムポリシー視点で具体的に整理します。
シームレス移行で起きがちな「REST API検証が動かない」問題
Azure AD B2C(現在は Microsoft Entra External ID として整理される領域もあります)でユーザーを移行するとき、全ユーザーをいきなり作り直すのではなく、初回サインイン時に既存の認証基盤(レガシー)で本人確認してからB2C側へパスワードを移す方式が「シームレス移行(Seamless migration)」としてよく使われます。
ただし、この方式は「B2Cのカスタムポリシー(Custom policy / IEF)」の中で REST 呼び出し(REST API コール)を行うため、“B2Cが期待する入出力の形”を外すと、Postman では成功するのに B2C から呼ぶと失敗する、という現象が起きやすいです。
今回の要件と典型フロー
要件を整理すると、次のような構成です。
| 項目 | 内容 | 狙い |
|---|---|---|
| 事前移行 | ユーザー自体はB2Cに作成済み(仮パスワード発行などで完了) | 初回ログイン前にユーザーを確保し、ID連携や属性を先に揃える |
| 移行判定 | extension_isMigrated が false のユーザーのみ対象 | 既に移行済みのユーザーはB2C内で完結させる |
| レガシー検証 | 未移行なら「メール+パスワード」をレガシーAPI(例:.NET Core 2.1)へPOSTして認証 | 本当に本人が知っているパスワードか確認する(初回だけ) |
| 成功時 | B2C側のパスワードを入力値で更新し、extension_isMigrated を true に更新してログイン継続 | 次回からはレガシー不要。B2Cだけでサインインできる |
このとき、カスタムポリシー側には最低限次のような Claim が登場します(名称は一例です)。
| Claim名(例) | 型 | 用途 | よくある落とし穴 |
|---|---|---|---|
signInName / email | string | ユーザー識別(ログインID) | 入力画面のClaim名とREST送信のキー名がズレる |
password | string | ユーザー入力の平文パスワード(検証用) | ログ出力で漏らさない/API側で不用意に保存しない |
extension_isMigrated | boolean | 移行済み判定 | 初期値の付け忘れ、あるいは文字列で入って判定がブレる |
isValidPassword | boolean | レガシーAPI検証結果を受ける | APIレスポンス形式が合わず claim が空のままになる |
症状:Webhookでは正しいのに、Postmanと結果が一致しない
よくある相談は次のようなものです。
- API側のデバッグでは
email.Substring(...)などでNullReferenceExceptionが出る - Webhook(リクエストを受けて中身を表示するサービス)で見ると JSON は正しそう
- Postmanで同じJSONを投げると期待通りに通る
- B2Cのポリシー上では「レガシー検証に失敗した」扱いになり、後続ステップへ進めない
この段階で「B2Cが送っているリクエストが壊れているのでは?」と疑いがちですが、実際には レスポンス側の形式が原因で、B2Cが結果を解釈できずフローが失敗しているケースが非常に多いです。
結論:B2CはREST APIレスポンスを“OutputClaimsにマッピングできる形”でないと扱えない
カスタムポリシーの REST 技術プロファイル(Technical Profile)は、ざっくり言うと次の前提で動きます。
- B2CがAPIに送るのは「InputClaims(claimの集合)」
- APIから返ってきたレスポンスは「OutputClaims(claimへ取り込む値)」
- レスポンスJSONのキー=Claim名(またはPartnerClaimType)として読み取る
ここで問題になるのが、レガシーAPIのレスポンスが true / false のような“値だけ”の場合です。これだと B2C は「どのClaimに入れればよいか」を判断できず、結果として OutputClaims に値が入らない(またはエラー扱い)になります。
| レスポンスの例 | B2C側での解釈 | 起きること |
|---|---|---|
true | キーが無いのでOutputClaimsにマップできない | isValidPassword が空(null)扱いになり、後続判定が破綻する |
"true" | 同上(文字列でも同じ) | 型変換もできず、成功/失敗の分岐ができない |
{ "isValidPassword": true } | キーがclaim名と一致するため取り込める | ポリシー内で isValidPassword を条件分岐に使える |
つまり、Webhookで「リクエストJSONが正しそう」に見えても、B2C側で失敗している本質は レスポンス→Claimへの取り込みが成立していないことにあります。これが、PostmanとB2Cで結果が一致しない最大の理由になります。
解決策:APIの返却を「プロパティ付きJSONオブジェクト」に変更する
対処はシンプルで、APIの返却を次の形に揃えます。
- NG:
true(値だけ) - OK:
{ "isValidPassword": true }(キー付きオブジェクト)
キー名は、ポリシー側の OutputClaimsで受けたい Claim 名に合わせます。たとえば OutputClaims で isValidPassword を受けるなら、レスポンスも isValidPassword にします。別名にしたいなら PartnerClaimType を使って一致させます。
リクエスト/レスポンスの具体例
「B2Cが実際に送り、B2Cが実際に受け取れる形」を固定すると、検証がとても楽になります。
APIへ送るリクエスト例(Body)
{
"email": "[email protected]",
"password": "P@ssw0rd!"
}
成功時のレスポンス例
{
"isValidPassword": true
}
認証NG(パスワード不一致など)のレスポンス例
{
"isValidPassword": false
}
.NET Core 2.1 側の実装例
ASP.NET Core 2.1 で「true/falseだけ」を返している場合は、次のように“オブジェクトとして返す”ように修正します。
// NG:値だけ返す
// return Ok(true);
// OK:プロパティ付きのJSONとして返す
return Ok(new { isValidPassword = true });
受信側も、B2Cから来る可能性のある差分(キー名・空値)に強くしておくと、検証中の例外が減ります。
public class LegacyValidateRequest
{
public string Email { get; set; }
public string Password { get; set; }
}
[HttpPost("auth/validate")]
public IActionResult Validate([FromBody] LegacyValidateRequest req)
{
// req自体がnullのケースやEmailが空のケースを先に弾く
if (req == null || string.IsNullOrWhiteSpace(req.Email) || string.IsNullOrWhiteSpace(req.Password))
{
return Ok(new { isValidPassword = false });
}
// 以降、レガシー基盤で照合(ここでパスワードをログに出さない)
var ok = LegacyAuthenticator.Verify(req.Email, req.Password);
return Ok(new { isValidPassword = ok });
}
また、次のヘッダーが返っていることを必ず確認してください。
Content-Type: application/json- 文字コードは通常 UTF-8(明示されていても問題ないことが多い)
カスタムポリシー側:REST 技術プロファイルの最小構成
ポリシー側では、REST呼び出しの技術プロファイルで「入力(email/password)」「出力(isValidPassword)」をはっきりさせます。以下は最小イメージです(環境に合わせて調整してください)。
<TechnicalProfile Id="REST-LegacyPasswordValidate">
<DisplayName>Validate password against legacy API</DisplayName>
<Protocol Name="Proprietary"
Handler="Web.TPEngine.Providers.RestfulProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
<Metadata>
<Item Key="ServiceUrl">https://api.example.com/auth/validate</Item>
<Item Key="AuthenticationType">None</Item>
<Item Key="SendClaimsIn">Body</Item>
<Item Key="AllowInsecureAuthInProduction">false</Item>
</Metadata>
<InputClaims>
<InputClaim ClaimTypeReferenceId="signInName" PartnerClaimType="email" Required="true" />
<InputClaim ClaimTypeReferenceId="password" PartnerClaimType="password" Required="true" />
</InputClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="isValidPassword" PartnerClaimType="isValidPassword" />
</OutputClaims>
<UseTechnicalProfileForSessionManagement ReferenceId="SM-Noop" />
</TechnicalProfile>
ここで重要なのは次の2点です。
- InputClaimsのPartnerClaimType:APIが受け取るJSONキーに合わせる(例:
"email") - OutputClaimsのPartnerClaimType:APIが返すJSONキーに合わせる(例:
"isValidPassword")
この対応関係が1文字でもズレると、B2Cは値を取り込めません。大文字小文字、アンダースコア、キャメルケースまで含めて合わせるのが安全です。
同じ症状に見える別の原因もある:入力と出力を切り分ける
今回の原因は「レスポンスが値だけ」でしたが、現場では同じような見た目になる別要因もあります。短時間で切り分けるために、代表例をまとめます。
| 原因 | 見え方 | 最短チェック | 対処 |
|---|---|---|---|
| InputClaimsのキー名ズレ(PartnerClaimType未設定など) | APIで email が null になる | APIの生ログで受信JSONのキーを確認 | PartnerClaimType を APIの期待キーに合わせる |
| Content-Type が想定外 | モデルバインドが失敗し req が null になる | Headerの Content-Type をAPI側で記録 | B2C側は通常JSONだが、API側の受け取り設定も確認する |
| レスポンスが値だけ(今回) | B2C側でOutputClaimsが空になる | レスポンス本文を生で確認 | トップレベルのキー付きJSONに変更する |
| レスポンスのキー名ズレ | 取り込みたいclaimに値が入らない | OutputClaimsのPartnerClaimTypeとレスポンスキーを照合 | キー名を一致させる(大文字小文字まで) |
全体の分岐設計:extension_isMigrated が false のときだけRESTを呼ぶ
シームレス移行のポイントは「移行済みユーザーには余計な処理をしない」ことです。概念的には次のような分岐になります。
| 条件 | 実行すること | ユーザー体験 |
|---|---|---|
extension_isMigrated = true | 通常のB2Cローカルアカウントサインイン | 今まで通り(高速) |
extension_isMigrated = false | レガシーAPIで検証 → 成功ならB2Cへパスワード書き込み → フラグ更新 | 初回だけ少し遅いが、次回からは快適 |
この「初回だけ」のステップで、REST API検証結果(isValidPassword)が正しく入るかどうかが全ての分岐の起点になります。だからこそ、レスポンス形式が崩れると一気に詰みます。
API側のチェックリスト:B2Cと相性の良い返し方に揃える
レスポンス形式以外にも、B2Cから呼ぶときに差が出やすいポイントを、実務で効く順にチェックリスト化します。
| チェック項目 | 推奨 | 理由 |
|---|---|---|
| レスポンス形式 | { "isValidPassword": true/false } | OutputClaimsに確実にマッピングできる |
| HTTPステータス | 認証NGでも 200 を返し、結果はJSON内で表現 | 非2xxだと「API障害」扱いになり、ユーザーに意図しないエラーが出やすい |
| Content-Type | application/json | モデルバインディングとB2Cの解釈が安定する |
| JSONの階層 | トップレベルに出力キーを置く | ネストが深いと取り込みが難しくなることがある |
| Null耐性 | 入力が欠けても落ちない(nullチェック) | 一時的な不整合でもAPI側例外を避け、観測しやすくする |
| ログ | パスワードは絶対にログに出さない | セキュリティ事故を防ぐ(特に検証段階でやりがち) |
「PostmanではOKなのにB2CではNG」になりやすい思考の罠
Postmanで成功するのにB2Cで失敗すると、つい「B2Cのリクエストがおかしい」と決め打ちしがちです。しかし実際には、次の2つを分けて見ると原因に最短で辿り着けます。
| 観点 | 見るべきもの | 典型的な原因 |
|---|---|---|
| リクエスト(入力) | APIが本当に受け取った生のBody・Header | JSONキー名ズレ、Content-Type違い、空文字/Null、モデルの型違い |
| レスポンス(出力) | B2Cが期待する「トップレベルのキー付きJSON」 | true/falseだけ返している、キー名ズレ、非JSON |
今回のケースは後者で、レスポンスが値だけだったためにB2CがOutputClaimsへ入れられず、結果としてポリシー上の分岐が成立しませんでした。
デバッグを成功させるコツ:どこで値が消えたかを“見える化”する
カスタムポリシーのREST連携は、トラブル時に「どのClaimが、どの時点で、どうなっているか」が見えないと一生ハマります。以下の順で観測点を作ると、再現性のある切り分けができます。
API側:まずは「受け取った形」を固定で記録する
- 受信したJSONのキー名(email/password など)
- Content-Type
- Bodyのサイズ(0や極端に小さい場合は送信側の設定を疑う)
- 例外が出た場合のスタックトレース(ただしパスワードは出さない)
特に、email.Substring(...) のような処理は入力がnull/空文字でも落ちやすいので、検証中は次のようなガードを入れるだけで切り分けが一気に進みます。
if (string.IsNullOrWhiteSpace(email))
{
// ここで入力欠落を判定し、ログは最小限(パスワードは出さない)
return Ok(new { isValidPassword = false });
}
B2C側:OutputClaimsに値が入っているかだけをまず確認する
REST API呼び出し自体が成功していても、OutputClaimsへ取り込めなければ「成功したことになりません」。確認したいのは、次の一点です。
- REST呼び出し後に
isValidPasswordがtrue/falseのどちらかで必ず存在するか
ここが空なら、ほぼ間違いなくレスポンスJSONのキー名または構造が原因です。
シームレス移行として仕上げるための実装ポイント
REST検証が通るようになったら、次は「移行完了までの一連の書き込み」を丁寧に設計すると、運用が安定します。
パスワード更新とフラグ更新を分けて考える
- パスワード更新:B2Cディレクトリへ「入力パスワード」を書き込む技術プロファイルを呼ぶ
- 移行フラグ更新:
extension_isMigratedをtrueにして、次回からREST検証をスキップさせる
どちらか片方だけ成功すると中途半端な状態になります。たとえば「パスワードは更新されたのにフラグがfalseのまま」だと、毎回REST検証が走ってしまいます。逆に「フラグだけtrueになったがパスワード更新が失敗」だと、次回以降ログインできなくなる危険があります。
エラー表示は“ユーザーに優しい”粒度で
レガシー認証が失敗したとき、ユーザーへ出すメッセージは次の原則が安全です。
- 「メールアドレスが存在しません」などのアカウント列挙につながる文言は避ける
- 「メールアドレスまたはパスワードが正しくありません」のように統一したメッセージにする
- API障害(タイムアウト等)の場合は、ユーザーに再試行を促しつつ、運用側で検知できるログを残す
実務で効く追加改善:レガシーAPIを“B2C向け”に堅牢化する
今回の修正(プロパティ付きJSON)だけでも動きますが、シームレス移行を本番で回すなら、API側を少しだけB2C向けに寄せておくと事故が減ります。
レスポンスに診断用の情報を足す(ただし公開しすぎない)
例えば、運用者が調査しやすいように、成功/失敗以外の情報を返したくなることがあります。その場合は、B2Cへ取り込むClaimと、運用ログ用の情報を切り分けましょう。B2Cに渡すのは最小限が基本です。
// 例:B2Cに渡すのは isValidPassword だけ(推奨)
return Ok(new { isValidPassword = true });
どうしても追加したいなら、B2Cのカスタムポリシーで使うClaimとして明示し、取り扱い(ログに出す/出さない)も決めてからにします。
タイムアウトとリトライを想定する
REST連携は「レガシーAPIが遅い/落ちる」とログイン不能に直結します。移行期間中だけでも、以下を検討すると現場が楽になります。
- APIのスケール(同時ログインピークに耐えられるか)
- 依存しているDBや外部サービスのレイテンシ
- 障害時の代替導線(パスワードリセット、サポート窓口など)
まとめ:REST検証が不安定なときは“レスポンスの形”を最初に疑う
シームレス移行のカスタムポリシーで REST API 検証がうまくいかないとき、最初に確認したいのは 「B2CがOutputClaimsへ取り込めるレスポンスになっているか」です。
- レガシーAPIは
true/falseの値だけではなく、{ "isValidPassword": true/false }のように返す - Content-Type: application/json を返す
- ポリシー側は OutputClaims の claim 名(または PartnerClaimType)とレスポンスのキーを一致させる
ここが揃うだけで、PostmanとB2Cの差分は一気に解消し、移行フローの後半(パスワード更新・フラグ更新)に集中できるようになります。

コメント