ASP.NET 4.8 + MSAL を使った Web アプリをローカル開発では問題なく動かせたのに、本番の IIS に載せた途端「UserInteractive モードで実行されていない状態でモーダルダイアログを表示するのは不正な操作です」という例外で認証ポップアップが出ない――この現象は、MSAL の設計と IIS(非対話環境)の仕組みが噛み合っていないことが原因です。本記事では、その本質と正しい解決パターンを Web アプリ視点で整理します。
IIS 本番環境で MSAL のポップアップ認証が動かない現象
今回のケースは、ASP.NET 4.8 の Web アプリケーションで MSAL を利用し、次のようなコードをサーバー側で呼び出しているパターンです。
var app = PublicClientApplicationBuilder.Create(clientId)
.WithAuthority(authority)
.Build();
var result = await app.AcquireTokenInteractive(scopes)
.WithUseEmbeddedWebView(true)
.ExecuteAsync();
開発機では Visual Studio から起動しているため、プロセスはログオン中ユーザーのデスクトップと紐づいた 対話モード(インタラクティブセッション) で動作しています。そのため埋め込み WebView を使ったポップアップ表示が可能です。
しかし、本番ではアプリケーションは IIS のワーカープロセス(w3wp.exe)として、サービス的な非対話モード で動作します。ここではデスクトップ UI を持たないため、サーバー側でモーダルダイアログを表示しようとすると、次のような例外が発生します。
UserInteractive モードで実行されていない状態でモーダル ダイアログを表示するのは不正な操作です。
つまり、この問題は「IIS だから MSAL が動かない」のではなく、サーバー側でクライアント向けの対話フロー(AcquireTokenInteractive)を使っていること自体が設計ミスという位置づけになります。
開発環境と本番環境の違いを整理する
| 項目 | 開発環境(ローカル) | 本番環境(IIS) |
|---|---|---|
| 実行プロセス | Visual Studio / IIS Express / コンソールなど | IIS ワーカープロセス (w3wp.exe) |
| セッション種別 | インタラクティブセッション(ユーザーのデスクトップ) | サービス・サーバーセッション(デスクトップ UI なし) |
| ユーザー操作 | デバッグ中の開発者が直接操作 | ブラウザ越しの利用者のみが操作 |
| ポップアップ表示 | ローカルのデスクトップ上に表示可能 | サーバーからは表示不可(誰も見られない画面になる) |
| MSAL フロー | 対話フロー (AcquireTokenInteractive) でも動く | 対話フローは根本的に不適合 |
このように、本番 IIS 環境では「サーバーが画面を持っている」という前提は成立しないため、ポップアップ UI 前提の API は採用できません。
MSAL のクライアント種別と Web アプリで選ぶべきフロー
MSAL ではアプリケーションの性質に応じて、主に次の 2 種類のクライアントを使い分けます。
- Public client(公開クライアント):WPF / WinForms / モバイルアプリなど、ユーザーのデバイス上で動く UI アプリを想定
- Confidential client(機密クライアント):Web アプリ / Web API / バッチサーバーなど、クライアントシークレットや証明書を安全に保持できるサーバーアプリを想定
| クライアント種別 | 主な利用環境 | 代表的なメソッド | Web アプリとの相性 |
|---|---|---|---|
| PublicClientApplication | WinForms / WPF / UWP / モバイル / SPA など | AcquireTokenInteractive, AcquireTokenSilent | サーバー上では非推奨(クライアントシークレットを持てない前提) |
| ConfidentialClientApplication | ASP.NET / ASP.NET Core / Web API / バッチサーバー | AcquireTokenByAuthorizationCode,AcquireTokenForClient,AcquireTokenOnBehalfOf | Web アプリで使うべきクライアント |
今回のような IIS 上の Web アプリでは、PublicClientApplication + AcquireTokenInteractive という組み合わせそのものが不適切であり、ConfidentialClientApplication を使ったサーバー向けフローに置き換える必要があります。
解決策 A:Web アプリは認可コードフロー(OpenID Connect)へ切り替える
ユーザーにブラウザでサインインしてもらい、そのユーザーとして Microsoft Graph など下流 API を呼び出したい場合、最もオーソドックスで推奨されるのが 認可コードフロー です。
認可コードフローでは、サーバーが UI を表示するのではなく、ブラウザをログインページにリダイレクトし、サインイン完了後に認可コード(authorization code)をサーバーが受け取ります。その認可コードを使って、サーバー側の ConfidentialClientApplication でアクセストークンに交換します。
Microsoft Entra ID(旧 Azure AD)のアプリ登録
まずは Microsoft Entra ID 側のアプリ登録を Web アプリ向けに整えます。
- アプリ種別:Web アプリ(機密クライアント)
- リダイレクト URI:本番 URL の HTTPS を設定(例:
https://example.com/signin-oidc) - クライアント資格情報:クライアントシークレットまたは証明書を発行
- 委任されたアクセス許可(スコープ):例)
User.Read,Files.Read,Mail.Readなど
開発用に http://localhost を登録したまま本番へ持ち込むと、リダイレクト URI 不一致で認証エラーになるので、本番用 URL を必ず追加しておきましょう。
ASP.NET 4.8(OWIN)での実装イメージ
ASP.NET 4.8 では、OWIN の OpenID Connect ミドルウェアと組み合わせるのが定番です。Startup.Auth.cs などに次のような設定を追加します。
public void ConfigureAuth(IAppBuilder app)
{
// 認証クッキー
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = "Cookies"
});
var clientId = ConfigurationManager.AppSettings["ClientId"];
var tenantId = ConfigurationManager.AppSettings["TenantId"];
var authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
var redirectUri = ConfigurationManager.AppSettings["RedirectUri"];
var clientSecret = ConfigurationManager.AppSettings["ClientSecret"];
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
ClientId = clientId,
Authority = authority,
RedirectUri = redirectUri,
// 推奨:code フロー
ResponseType = "code",
Scope = "openid profile offline_access User.Read Files.Read",
SignInAsAuthenticationType = "Cookies",
Notifications = new OpenIdConnectAuthenticationNotifications
{
AuthorizationCodeReceived = async notification =>
{
var cca = ConfidentialClientApplicationBuilder
.Create(clientId)
.WithAuthority(authority)
.WithRedirectUri(redirectUri)
.WithClientSecret(clientSecret) // または証明書
.Build();
var scopes = new[] { "User.Read", "Files.Read" };
var result = await cca.AcquireTokenByAuthorizationCode(scopes, notification.Code)
.ExecuteAsync();
// result.AccessToken / result.Account などをトークンキャッシュへ保存
// ユーザー単位のキャッシュに紐づけることがポイント
}
}
});
}
この構成では、ユーザーが未サインインの状態で保護されたページにアクセスすると、OWIN が自動的に Microsoft Entra ID へリダイレクトし、ブラウザでサインイン画面が表示されます。サーバーは一切ポップアップ UI を表示せず、HTTP リダイレクトだけでフローを完結させます。
認可コードフロー導入時の実装ポイント
- PublicClientApplication は使わない:Web アプリでは ConfidentialClientApplication に統一する
AcquireTokenInteractiveは完全に撤去し、AcquireTokenByAuthorizationCodeを使用する- トークンはクライアント側ではなく、サーバー側のキャッシュで管理する(セッション ID と紐づけるなど)
- Cookie 認証 + トークンキャッシュを組み合わせることで、ユーザーには「一度ログインしたらしばらくそのまま使える」体験を提供する
- スコープと実際の Graph API 呼び出し内容を対応付けて設計し、最小権限の原則を守る
解決策 B:バックグラウンド処理だけならクライアント資格情報フロー
もしシナリオが「ユーザーの代わりではなく、アプリ自身としてバッチ処理を行いたい」「夜間ジョブで SharePoint / Graph にアクセスしたい」といった バックグラウンド処理専用 であれば、クライアント資格情報フロー が適しています。
このフローではユーザーのサインインは一切不要であり、アプリケーションの ID(クライアント ID)とシークレット(または証明書)のみでトークンを取得します。
var clientId = ConfigurationManager.AppSettings["ClientId"];
var tenantId = ConfigurationManager.AppSettings["TenantId"];
var authority = $"https://login.microsoftonline.com/{tenantId}";
var clientSecret = ConfigurationManager.AppSettings["ClientSecret"];
var app = ConfidentialClientApplicationBuilder
.Create(clientId)
.WithAuthority(authority)
.WithClientSecret(clientSecret) // 証明書でも可
.Build();
// .default でアプリケーション権限をまとめて指定
var scopes = new[] { "https://graph.microsoft.com/.default" };
var result = await app.AcquireTokenForClient(scopes).ExecuteAsync();
string accessToken = result.AccessToken;
// このトークンで Graph API などを呼び出す
クライアント資格情報フローでできること / できないこと
| 項目 | 内容 |
|---|---|
| できること | 組織全体のユーザー情報取得、共有メールボックス、システム的な SharePoint 操作など「アプリケーション権限」で許可された範囲の処理 |
| できないこと | 特定ユーザーの個人 OneDrive やメールボックスへの「その人になりすましたアクセス」(委任権限が必要なシナリオ) |
| 注意点 | Microsoft Graph 側でアプリケーションのアクセス許可を付与し、管理者の同意を得る必要がある |
クライアント資格情報フローは非常に強力ですが、その分権限も広くなりがちです。最小限の権限に絞り込み、シークレットや証明書の保護(Key Vault など)も含めて、運用設計を慎重に行う必要があります。
解決策 C:ユーザー操作は必要だがサーバーに UI は出せない場合
「ユーザーの操作は必要だが、アプリはコンソールアプリやバッチ、または UI を持たないプロセス」というケースもあります。この場合、次のような選択肢があります。
デバイスコードフロー(最終手段)
コンソールアプリやバッチプロセスでは、デバイスコードフローを利用する方法があります。サーバー側のコンソールに表示したコードを、ユーザーが別のブラウザやスマホで入力することで認証を完了させる仕組みです。
var app = PublicClientApplicationBuilder.Create(clientId)
.WithAuthority(authority)
.Build();
var result = await app.AcquireTokenWithDeviceCode(scopes, deviceCodeResult =>
{
Console.WriteLine(deviceCodeResult.Message);
return Task.CompletedTask;
}).ExecuteAsync();
ただし、Web アプリの一般的なユーザー向け UX としてはかなり特殊です。通常の IIS ホスト Web アプリでは推奨されず、「どうしても」というケースのみの最終手段として考えるべきです。
フロントエンドに認証を移して On-Behalf-Of フローを利用する
もう一つ現実的な構成として、ブラウザ側でサインインを完結させ、サーバー API では On-Behalf-Of(OBO)フローでトークンを取得する方法があります。
- クライアント:SPA(React / Angular など)や MVC / Razor ページで MSAL.js を使ってサインイン
- サーバー API:クライアントから渡されたトークンを元に、
AcquireTokenOnBehalfOfで下流 API 用トークンを取得
OBO フローのざっくりした流れは次のとおりです。
- ブラウザ(MSAL.js)でユーザーがサインインし、ID トークン / アクセストークンを取得
- そのトークンを Bearer トークンとして ASP.NET Web API に送信
- Web API 側で MSAL ConfidentialClientApplication を使い、
AcquireTokenOnBehalfOfを呼び出して下流 API 用のトークンを取得 - Web API が Microsoft Graph などをユーザーの代わりに呼び出す
この構成では、ブラウザが直接サインイン画面を表示するため、サーバーに UI を出す必要がありません。IIS でも問題なく動作し、拡張性の高い構成が取れます。
やってはいけない実装パターンとよくある罠
ここまでの内容を踏まえ、IIS 本番環境で避けるべき実装や、よくハマるポイントをまとめます。
サーバーで AcquireTokenInteractive / 埋め込み WebView を使う
もっとも直接的な NG パターンです。IIS のワーカープロセスは UI を持たないため、たとえ例外が出なくても、実際に表示される画面を誰も見ることができません。サーバー側でポップアップを出さず、ブラウザのリダイレクトで認証を完結させる設計に切り替えましょう。
Web アプリで PublicClientApplication を使う
PublicClientApplication は「クライアントシークレットを安全に保持できない前提」のアプリ向けです。IIS 上の Web アプリではクライアントシークレットや証明書を安全に保持できるため、ConfidentialClientApplication を使うのが正解です。
開発用の localhost リダイレクト URI のまま本番にデプロイ
開発中に http://localhost などを使っていると、そのまま本番へデプロイして「本番ではリダイレクトエラー」というパターンが頻出します。
- 本番用 URL(
https://)を Entra ID のアプリ登録に追加する - HTTP ではなく HTTPS を利用する(特に本番環境では必須)
このあたりの設定漏れは、MSAL のコードを見るだけでは気付きにくいので、アプリ登録・リダイレクト URI 設定とセットで確認するチェックリストを持っておくと安全です。
スコープと権限種別(委任権限 vs アプリケーション権限)の混同
Graph の権限は大きく「委任されたアクセス許可(ユーザーとしてのアクセス)」と「アプリケーションのアクセス許可(アプリ自身としてのアクセス)」に分かれます。認可コードフローは前者、クライアント資格情報フローは後者を前提としているため、どのフローでどの権限を使うかを整理しないと、思った通りに動きません。
| フロー | 主な権限種別 | 典型的なシナリオ |
|---|---|---|
| 認可コードフロー | 委任されたアクセス許可 | ログイン中ユーザーの OneDrive / メール / プロフィール取得 |
| クライアント資格情報フロー | アプリケーションのアクセス許可 | 組織全体のユーザー一覧取得、夜間バッチでの処理 |
| OBO フロー | 委任されたアクセス許可 | SPA + Web API 構成で API 側から Graph を呼ぶ |
どのフローを選べばよいか早見表
実際に設計するとき、「どのフローを選べばいいかわからない」という場面は多いです。用途別に、おおまかな選択基準をまとめます。
| 目的 / 形態 | 推奨フロー | 主な API / 実装ポイント |
|---|---|---|
| ユーザーがブラウザでサインインする ASP.NET Web アプリ | 認可コードフロー | OWIN OpenID Connect + ConfidentialClientApplication+ AcquireTokenByAuthorizationCode |
| サーバーだけで動く定期ジョブ・バッチ | クライアント資格情報フロー | ConfidentialClientApplication+ AcquireTokenForClient+ アプリケーション権限 |
| WPF / WinForms などのデスクトップアプリ | 対話フロー | PublicClientApplication+ AcquireTokenInteractive |
| SPA + Web API(フロントエンドでサインイン) | ブラウザ認証 + OBO フロー | クライアント:MSAL.js サーバー: AcquireTokenOnBehalfOf |
| サーバープロセスで UI は出せないが、ユーザー操作は必要 | デバイスコードフロー(最終手段) | AcquireTokenWithDeviceCode |
既存コードからの移行ステップ例
すでに本番で AcquireTokenInteractive(...).WithUseEmbeddedWebView(true) を使っている場合、無理にそれを修正しようとするより、フローごと置き換える方が結果的に早くて安全です。移行の流れを一例として示します。
- 現状のトークン取得箇所を洗い出す
どのコントローラー / サービスクラスでAcquireTokenInteractiveを使っているかを一覧化します。 - シナリオを分類する
「ユーザーの代わりとして Graph を呼びたい」のか、「バッチ的にアプリとして Graph を呼びたい」のかを用途別に分けます。 - 認可コードフロー or クライアント資格情報フローを選択
ユーザー単位なら前者、アプリ単位なら後者を選びます。混在している場合は、それぞれに分割する方がわかりやすくなります。 - Microsoft Entra ID のアプリ登録を作り直す / 修正する
フローに応じてリダイレクト URI や権限を整理し、不要な権限を削ります。 - ASP.NET 側の認証ミドルウェアを設定
OWIN の OpenID Connect 設定と、ConfidentialClientApplication の初期化処理を追加します。 - トークンキャッシュの設計を見直す
ユーザー固有のキー(oidやtid)と紐づけたサーバー側キャッシュに置き換えます。 - ローカル開発環境でフローを確認
IIS Express などで動作を確認し、エラー時のログをしっかり出力するようにします。 - ステージング / 本番へ順次適用
いきなり本番へ入れず、テスト環境で SSO やトークンの有効期限などを検証してから本番に昇格させます。
トラブルシューティング:それでもうまく動かないときの確認ポイント
設計を見直しても問題が残る場合、次のポイントを一つずつ潰していくと原因に辿り着きやすくなります。
- 例外メッセージとスタックトレースを必ずログに出す
try-catch で握り潰さず、ログに残しておくことで後追い調査が容易になります。 - MSAL のログ機能を有効にする
MSAL にはロギング機構があるため、レベルを上げて実際のリクエスト / レスポンスの流れを確認します(機密情報はマスクする設定にする)。 - リダイレクト URI の完全一致を確認する
末尾のスラッシュの有無やポート番号、http / https の違いなど、細かい差異がエラー原因になることがあります。 - 時刻のずれ(NTP 設定)
トークンの有効期限検証は時刻に敏感です。サーバーの時刻が大きくずれていると、トークンが即座に無効と判断される場合があります。 - 証明書の配置と権限
クライアント証明書を使う場合、IIS のアプリケーションプールの ID が証明書にアクセスできるかどうかを確認します。
まとめ:Web アプリでは「サーバーでポップアップを出さない」設計に切り替える
本記事で扱ったポイントを改めて整理します。
- IIS 本番環境は非対話モードで動作するため、サーバー側でポップアップ UI を出すこと自体が設計ミスになる
AcquireTokenInteractiveはクライアントアプリ向けの対話フローであり、Web アプリでは利用しない- Web アプリでは ConfidentialClientApplication + 認可コードフロー(
AcquireTokenByAuthorizationCode)を基本とする - バックグラウンド処理は クライアント資格情報フロー(
AcquireTokenForClient)に切り替え、アプリケーション権限で実装する - SPA + API 構成ではブラウザ側で認証し、On-Behalf-Of フローで下流 API 用トークンを取得する
- 既存の
AcquireTokenInteractive(...).WithUseEmbeddedWebView(true)は潔く撤去し、用途に応じて適切なフローへ実装を置き換えることが最終的な解決策
「開発機では動いていた MSAL のポップアップ認証が、本番 IIS では動かない」という現象は、本質的にはフレームワークやライブラリの不具合ではなく、クライアント向けフローをサーバーに持ち込んでしまった設計のミスマッチです。この記事をきっかけに、Web アプリとして正しい認証フローを選び直し、安定した本番運用につなげていただければ幸いです。

コメント