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 を組み合わせるのが現実的

コメント