Blazor Server(ASP.NET Core Identity)のMicrosoftログインで住所・電話番号が取れない原因とMicrosoft Graph APIでの取得方法

Blazor Server で ASP.NET Core Identity の外部ログインに Microsoft アカウントを追加したのに、ログイン後の Claims に住所や電話番号が見当たらない…。これは「スコープ追加=クレーム増加」ではないことが原因になりがちです。Microsoft Graph API を使った取得方法と、任意同意を前提にした実装の考え方をまとめます。

目次

Blazor Server(ASP.NET Core Identity)× Microsoft 外部ログインで「住所・電話番号が取れない」と感じる理由

結論から言うと、Microsoft ログインが成功しても、住所・電話番号のような詳細プロフィールが自動でクレーム(Claims)に入るとは限りません。特に Blazor Server で ASP.NET Core Identity を使っている場合、ログイン後に参照している Principal(ClaimsPrincipal)が「外部プロバイダーの Principal」ではなく「アプリ(Identity)のサインイン Cookie の Principal」になっているため、余計に混乱しやすいです。

まず、よくある期待と実際のギャップを整理します。

やりたいことよくある期待現実に起きやすいこと正しいアプローチ
User.Read を追加したいScope を追加すれば住所・電話番号が Claims に増えるScope を足しても Claims は増えない(または増えたように見えない)Graph を呼び出し、必要ならアプリ側で Claims/DB に反映する
住所・電話番号を取得したいトークンや標準クレームに載ってくる詳細情報はトークン/クレームに載らないことが多いアクセストークンで Graph API(/me)を呼ぶ
ログイン後(アプリ内)で参照したいOnCreatingTicket で追加したクレームがそのまま残る外部ログイン時の Principal と、Identity の Principal は別物ユーザークレームとして保存、またはプロフィールとして保存して取り出す
任意で同意してもらいたい住所だけ/電話だけを選ばせられる同意は基本「項目」ではなく「スコープ(権限)」単位最小権限でログイン→必要時に追加スコープ同意(追加承認)を要求

最初に押さえる:Scope、Token、Claims、Principal の関係

この問題は、言葉が似ていて混同しやすい概念が重なって発生します。ここをクリアにすると、実装が一気に楽になります。

用語意味よくある勘違い現場でのポイント
Scope(スコープ)外部 API に対して「何をしてよいか」を示す権限の範囲スコープを足すと Claims が増えるスコープは「Graph を呼べるようになる」ための条件で、Claims は別の仕組みで増やす
Access Token(アクセストークン)Graph などの API を呼ぶための通行証アクセストークンに住所・電話番号が入っているはず多くの場合、トークン自体に詳細プロフィールは載らない。API 呼び出しが前提
Claims(クレーム)アプリが認証後に使う「ユーザー属性」外部で取れた情報は自動で全部クレームになるどの JSON をどの Claim に写すかは設定次第。詳細情報は自分で追加する
Principal(ClaimsPrincipal)現在のユーザーを表すコンテナ(ClaimsIdentity の集合)外部ログイン時の Principal とログイン後の Principal は同じIdentity を使うと「外部ログイン用」と「アプリ用」の Principal が切り替わる

さらに重要なのが、ASP.NET Core Identity の外部ログインは、ざっくり以下の流れで動く点です。

  • Microsoft 側で認証(外部プロバイダー)→ 戻ってきたタイミングでは「外部の Principal」を持っている
  • Identity が外部ログイン情報を受け取り、アプリ内ユーザー(AspNetUsers)と紐付けてサインインし直す
  • 以降、Blazor Server が参照するのは「アプリ(Identity)側の Cookie から復元された Principal」

つまり、外部プロバイダーの Principal にクレームを追加しても、そのままアプリ側の Principal に永続化されるとは限りません。ここが「スコープ足したのに増えていない」「確かに取ったのに見えない」の最大の原因です。

「User.Read を足したのに増えない」:AddMicrosoftAccount の落とし穴

AddMicrosoftAccount を使うケースでは、User.Read が(環境や実装上)既定で含まれている扱いになりやすいため、options.Scope.Add("User.Read") を追記しても「目に見える変化」が起きないことがあります。さらに、たとえ権限が増えたとしても、Principal(Claims)に自動で追加されるかどうかは別問題です。

外部ログインのハンドラーは、多くの場合「最低限のプロフィール」だけを取り込むように作られており、住所・電話番号のような情報は、次の理由で取り込まれないことが多いです。

  • ユーザー情報エンドポイント(Graph の /me)が既定で返す項目が限定的($select を付けないと返らない項目がある)
  • 返ってきても ClaimActions のマッピングがない(JSON → Claim 変換の設定がされていない)
  • そもそもアカウント側に住所・電話番号が登録されていない(値が null/空)
  • 外部 Principal に入っても、Identity の Cookie に保存していない

基本方針:住所・電話番号は Microsoft Graph API を呼んで取得する

住所・電話番号などの詳細プロフィールを確実に扱いたいなら、基本方針はこれです。

アクセストークンを使って Microsoft Graph API を別途呼び出し、必要な項目を取得して、アプリ側で保存またはクレーム化する。

Graph の呼び出し先としては、まずは https://graph.microsoft.com/v1.0/me が定番です。さらに、住所など「既定の返却セットに入らない項目」を取りたい場合は、$select を明示します。

例(取得対象の一例):

  • 電話番号:mobilePhone、businessPhones(配列)
  • 住所:streetAddress、city、state、country、postalCode

Graph のプロパティ名と、アプリ側で扱いやすい保存先の例をまとめます。

欲しい情報Graph の代表プロパティ型の注意アプリ側の保存先例
携帯電話mobilePhone文字列(null あり)ユーザープロフィール(DB)または Claim(短い値なら)
会社電話businessPhones配列(0件あり)DB(複数保持が自然)
住所(番地)streetAddress文字列(改行が入り得る)DB 推奨(Claim に載せると肥大化しやすい)
住所(市区町村)city文字列DB
住所(都道府県/州)state文字列DB
住所(国)country文字列DB
郵便番号postalCode文字列DB

なお、Graph で取れるからといって、必ず値が入っているとは限りません。組織アカウント(会社/学校)と個人 Microsoft アカウントで返る値が違ったり、ユーザーがそもそも登録していなかったりします。アプリ側は「取れたらラッキー」くらいの堅牢さで作るのが現実的です。

実装例:OnCreatingTicket で AccessToken を使い Graph を呼ぶ

外部ログインのタイミングで確実に Graph を叩きたいなら、Events.OnCreatingTicket に実装を置くのが分かりやすいです。ここでは context.AccessToken が使えるため、Bearer を付けて Graph にアクセスできます。

ポイント

  • options.SaveTokens = true; を付けて、トークンを後段でも扱えるようにする
  • $select で欲しいプロパティを明示する(住所系は特に)
  • 取得値を、その場で context.Identity.AddClaim(...) するか、後で DB 保存する
  • 値がない/プロパティがないケースを必ず考慮する(TryGetProperty)

Program.cs(または Startup 相当)の例:

using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.MicrosoftAccount;

builder.Services
    .AddAuthentication()
    .AddMicrosoftAccount(options =>
    {
        options.ClientId = builder.Configuration["Authentication:Microsoft:ClientId"]!;
        options.ClientSecret = builder.Configuration["Authentication:Microsoft:ClientSecret"]!;

        // 後からトークンを参照したい場合に重要
        options.SaveTokens = true;

        // 多くの構成では User.Read は既定で含まれていることがありますが、
        // 明示しておくことで意図が伝わりやすくなります
        options.Scope.Add("User.Read");

        // 既定のユーザー情報取得に加えて、追加で /me?$select=... を呼ぶ例
        options.Events.OnCreatingTicket = async context =>
        {
            if (string.IsNullOrEmpty(context.AccessToken) || context.Identity is null)
            {
                return;
            }

            // 住所・電話番号など、既定の返却セットに入らない項目は $select で明示
            var endpoint =
                "https://graph.microsoft.com/v1.0/me?$select=" +
                "id,displayName,mail,userPrincipalName," +
                "mobilePhone,businessPhones," +
                "streetAddress,city,state,country,postalCode";

            using var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken);

            using var response = await context.Backchannel.SendAsync(
                request,
                HttpCompletionOption.ResponseHeadersRead,
                context.HttpContext.RequestAborted);

            response.EnsureSuccessStatusCode();

            using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
            var root = doc.RootElement;

            // 取得した値を「アプリ側で使う」ためにクレームとして追加(必要最低限に)
            AddClaimIfNotEmpty(context.Identity, "urn:ms:mobile_phone", root, "mobilePhone");
            AddClaimIfNotEmpty(context.Identity, "urn:ms:street_address", root, "streetAddress");
            AddClaimIfNotEmpty(context.Identity, "urn:ms:city", root, "city");
            AddClaimIfNotEmpty(context.Identity, "urn:ms:state", root, "state");
            AddClaimIfNotEmpty(context.Identity, "urn:ms:country", root, "country");
            AddClaimIfNotEmpty(context.Identity, "urn:ms:postal_code", root, "postalCode");

            // businessPhones は配列なので必要なら連結して入れる(または DB 保存推奨)
            if (root.TryGetProperty("businessPhones", out var phones) && phones.ValueKind == JsonValueKind.Array)
            {
                var list = phones.EnumerateArray()
                    .Select(p => p.GetString())
                    .Where(s => !string.IsNullOrWhiteSpace(s))
                    .ToArray();

                if (list.Length > 0)
                {
                    context.Identity.AddClaim(new Claim("urn:ms:business_phones", string.Join(",", list)));
                }
            }
        };
    });

static void AddClaimIfNotEmpty(ClaimsIdentity identity, string claimType, JsonElement root, string jsonKey)
{
    if (!root.TryGetProperty(jsonKey, out var prop))
    {
        return;
    }
    if (prop.ValueKind != JsonValueKind.String)
    {
        return;
    }
    var value = prop.GetString();
    if (string.IsNullOrWhiteSpace(value))
    {
        return;
    }
    identity.AddClaim(new Claim(claimType, value));
}

この時点で「外部ログインの Principal」にはクレームが追加されます。しかし、ここで終わるとログイン後に Blazor 側で見える Claims に反映されないケースが出ます。理由は先述のとおり、Identity がアプリ用 Cookie を作るときに、外部 Principal のクレームを自動で取り込まないことがあるためです。

「ログイン後の Principal」に反映させる:保存戦略を決める

住所・電話番号を本当にアプリで使うなら、どこに保存するかを最初に決めるのが近道です。おすすめは「プロフィールとして DB に保存し、必要に応じて画面表示する」方式です。Claims は便利ですが、扱いを誤るとデメリットが目立ちます。

方式何をするメリット注意点
クレームとして追加して使うOnCreatingTicket などで AddClaim実装が手軽、認可に使いやすいサイズ肥大、更新が難しい、Identity Cookie へ永続化されない場合あり
Identity の UserClaims に保存外部ログイン時に UserManager.AddClaim(s)ログイン後の Principal に乗せやすい更新/差し替えロジックが必要(重複・履歴・消し込み)
独自プロフィールテーブルに保存(推奨)住所/電話を UserProfile などに格納柔軟、複数電話番号/住所にも対応しやすい参照コードが増える、認可は別途設計が必要
都度 Graph から取る必要になった画面で Graph 呼び出し常に最新、DB に PII を持たないトークン管理が難しい、遅延、障害時の UX

住所は長くなりがちで、改行や記号も含みます。Cookie/Claims に載せるより、DB に保存して画面で使う方が扱いやすいです。電話番号も「検証済みの番号かどうか」を別途持ちたくなることが多く、やはりプロフィールとして持つ設計と相性が良いです。

Identity の UserClaims として保存する例(外部ログイン後に永続化)

外部ログイン直後(コールバック処理)のタイミングで、外部 Principal に追加されたクレームを拾って Identity に保存します。既存のテンプレート(Identity UI)を使っている場合は、外部ログインのコールバックでユーザーを確定させる箇所に差し込むイメージです。

// 例:外部ログイン完了時のハンドラー内(概念例)
// info.Principal に外部プロバイダー側のクレームが入っている前提
var info = await _signInManager.GetExternalLoginInfoAsync();
if (info is null) { /* エラー処理 */ }

var user = await _userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey);
// またはメール等でユーザーを特定してもよい

if (user is not null)
{
    // 保存したいクレームだけを厳選(PII は最小限に)
    var targets = new[]
    {
        "urn:ms:mobile_phone",
        "urn:ms:street_address",
        "urn:ms:city",
        "urn:ms:state",
        "urn:ms:country",
        "urn:ms:postal_code"
    };

    var claimsToStore = info.Principal.Claims
        .Where(c => targets.Contains(c.Type) && !string.IsNullOrWhiteSpace(c.Value))
        .Select(c => new Claim(c.Type, c.Value))
        .ToList();

    if (claimsToStore.Count > 0)
    {
        // 既存の同種クレームを消してから入れ直すなど、運用に合わせて調整
        var existing = await _userManager.GetClaimsAsync(user);
        var toRemove = existing.Where(c => targets.Contains(c.Type)).ToList();
        if (toRemove.Count > 0)
        {
            await _userManager.RemoveClaimsAsync(user, toRemove);
        }

        await _userManager.AddClaimsAsync(user, claimsToStore);

        // すでにサインイン済みなら Principal を更新
        await _signInManager.RefreshSignInAsync(user);
    }
}

この方式なら、ログイン後に Blazor 側で参照する Principal(Identity の Cookie 由来)にも載りやすくなります。ただし、PII を UserClaims に積むのが適切かは運用次第です。住所までクレームに入れる必要がないなら、プロフィールテーブルに保存する方が安全で柔軟です。

Entra ID(アプリ登録)側で必要な設定:Graph を呼べるようにする

Graph API を呼ぶには、Microsoft Entra ID(旧 Azure AD)側のアプリ登録で、必要な委任権限(Delegated permissions)が付与されている必要があります。構成によっては「追加したつもりでも反映されていない」「同意が取れていない」ことで 403/401 になります。

最低限のチェックリストです。

  • アプリ登録で Microsoft Graph の委任権限に User.Read が入っている
  • テナント種別(組織/個人アカウント)を想定どおりに設定している
  • リダイレクト URI がアプリのコールバックと一致している
  • 必要なら(組織の運用次第で)管理者の同意が行われている

また、Graph の取得値が空の場合は、権限不足ではなく「ユーザー側に情報が登録されていない」ケースも多いです。実装では「空でも正常」として扱い、アプリ側の入力フォームで補完できる UX があると親切です。

「この情報の提供は任意です」を実現する現実的な設計

ユーザー体験として「住所・電話番号の提供は任意」と伝えたい気持ちはよく分かります。ただし、Microsoft の同意画面は通常、項目(住所/電話)単位ではなく、スコープ(権限)単位で表示されます。つまり「住所だけ OK」「電話だけ NG」のような細粒度同意を同意画面だけで作るのは難しいことが多いです。

そこで現実的なのは、次のどちらか(または組み合わせ)です。

方式A:最小構成でログイン → 取得ボタンを押した人だけ Graph を呼ぶ

ログイン後にアプリ内で次のように案内します。

  • 「Microsoft アカウントから住所・電話番号を取得できます(任意)」
  • 「取得する」を押した場合のみ Graph を呼び、取れた値だけ反映する
  • 取れなければ手入力フォームへ誘導する

この方式の良い点は、同意画面の細かさに頼らず、アプリ側の UX で「任意」を実現できることです。User.Read が既に付与されている構成なら、追加同意なしで取得できる場合もあります。

方式B:追加スコープが必要な設計にして「追加承認(インクリメンタル・コンセント)」を使う

例えば「後からも最新のプロフィールを同期したい」「バックグラウンドで参照したい」などで、より強い権限やトークン(例:offline_access 等)が必要になるなら、次の流れがきれいです。

  • まずは最小権限でログイン(アプリに入ることを最優先)
  • プロフィールを充実させたいタイミングで、追加スコープを要求して同意を取る
  • 同意後に Graph を呼び、プロフィールを更新する

重要なのは、ユーザーが「なぜ必要か」を理解できるようにすることです。アプリ側で、取得目的・保存期間・いつでも解除できることを明示すると、同意率もトラブルも減ります。

画面表示の例ユーザーに伝えるべきこと実装でやること
プロフィールを自動入力する(任意)取得する情報(住所/電話)と、用途(配送先/連絡先など)ボタン押下で Graph 取得(または追加スコープで再認証)
あとで変更できます手入力での上書き、取得の取り消しができることプロフィール編集画面、空にする導線、保存ロジック
同意の管理同意はスコープ単位であること、解除方法の案内必要なら「再同意」「接続解除」の導線を用意

よくあるトラブルとチェックポイント

context.AccessToken が null で Graph を呼べない

  • options.SaveTokens = true を付けているか確認
  • 外部ログインのイベントを仕込む場所が正しいか(AddMicrosoftAccount 内)
  • 例外が握りつぶされていないか(response.EnsureSuccessStatusCode を入れて原因を早めに出す)

Graph が 401/403 を返す

  • アプリ登録に User.Read があるか
  • 組織のポリシー上、管理者の同意が必要な構成になっていないか
  • 呼び出し先 URL のスペルや $select が正しいか

Graph は成功するが住所・電話が空

  • ユーザーが Microsoft 側でその情報を登録していない可能性が高い
  • 個人 Microsoft アカウントでは、組織アカウントより返る情報が少ない場合がある
  • アプリ側で手入力の代替導線を用意する

外部ログイン時にクレームを足したのに、ログイン後に見えない

  • 外部 Principal と、Identity の Principal は別物になり得る
  • 必要なら UserClaims やプロフィール DB に保存し、サインイン後に取り出す
  • 保存後に反映させたいなら RefreshSignInAsync を検討

実運用での要点:PII(個人情報)としての扱いを決める

住所・電話番号は、アプリにとって価値がある一方で、取り扱いを誤るとリスクが高い情報です。実装前に、最低限これだけは決めておくと事故が減ります。

項目決める内容実装への反映
取得目的何のために使うのか(連絡・配送・本人確認など)画面文言、同意導線、最小限の項目だけ取得
保存期間退会後の削除、一定期間で破棄するか削除ジョブ、退会処理、監査ログ
更新方法Graph で更新するか、手入力が正とするか編集画面、同期ボタン、上書きルール
セキュリティ誰が閲覧できるか、暗号化が必要か権限チェック、DB 暗号化、ログへの出力禁止

特に「クレームに住所を入れる」設計は、手軽に見えて後から困ることが多いです。住所が長文化して Cookie が太ったり、改行で表示が崩れたり、ログに出てしまったりします。プロフィールは DB、認可は必要最小限のクレームという分離が扱いやすいことが多いです。

まとめ:スコープ追加で終わらせず、Graph 呼び出しと保存設計まで一気通貫で考える

  • AddMicrosoftAccount で User.Read を追加しても、Principal(Claims)に自動で詳細情報が増えるわけではない
  • 住所・電話番号のような詳細プロフィールは、Microsoft Graph API をアクセストークンで呼び出して取得するのが基本
  • Events.OnCreatingTicket で Graph を呼び、必要なら context.Identity.AddClaim でクレーム追加できる
  • ただし、ログイン後に見える Principal が別物になり得るため、必要なら UserClaims/プロフィール DB に保存して反映させる
  • 「任意で提供してもらう」は、スコープ単位の同意とアプリ側 UX を組み合わせるのが現実的

この記事を書いた人

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

コメント

コメントする

目次