Microsoft Entra External ID(Azure AD B2C)シームレス移行でREST API検証が失敗する原因と対策|カスタムポリシーOutputClaims

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 / emailstringユーザー識別(ログインID)入力画面のClaim名とREST送信のキー名がズレる
passwordstringユーザー入力の平文パスワード(検証用)ログ出力で漏らさない/API側で不用意に保存しない
extension_isMigratedboolean移行済み判定初期値の付け忘れ、あるいは文字列で入って判定がブレる
isValidPasswordbooleanレガシー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-Typeapplication/jsonモデルバインディングとB2Cの解釈が安定する
JSONの階層トップレベルに出力キーを置くネストが深いと取り込みが難しくなることがある
Null耐性入力が欠けても落ちない(nullチェック)一時的な不整合でもAPI側例外を避け、観測しやすくする
ログパスワードは絶対にログに出さないセキュリティ事故を防ぐ(特に検証段階でやりがち)

「PostmanではOKなのにB2CではNG」になりやすい思考の罠

Postmanで成功するのにB2Cで失敗すると、つい「B2Cのリクエストがおかしい」と決め打ちしがちです。しかし実際には、次の2つを分けて見ると原因に最短で辿り着けます。

観点見るべきもの典型的な原因
リクエスト(入力)APIが本当に受け取った生のBody・HeaderJSONキー名ズレ、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の差分は一気に解消し、移行フローの後半(パスワード更新・フラグ更新)に集中できるようになります。

この記事を書いた人

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

コメント

コメントする

目次