ASP.NET Core(.NET 6 以降)で Azure AD(Microsoft Entra ID)と SAML SSO を組み合わせる際、Sustainsys.Saml2 v2 系だと ACS(Assertion Consumer Service)エンドポイントが /Saml2/Acs に固定されるため、API スタイルのルーティング(例:/api/Saml2/Acs)と整合しない――この“あるある”に正面から向き合い、何が原因で、どの選択肢が最も筋が良いのかを、実コード・設定例・デバッグ手順まで含めて徹底解説します。現場で起きる AADSTS50011 の落とし穴と回避策を、アップグレード/フォーク/リライトルール/ライブラリ乗り換えの観点で比較し、迷いなく適用できる決定版ガイドに仕上げました。
Sustainsys.Saml2 v2.x で ACS を /api/Saml2/Acs にできない問題の本質
Sustainsys.Saml2 は SP(Service Provider)側のミドルウェアとして広く使われていますが、v2 系ではモジュールのベースパス(SPOptions.ModulePath)を変更しても、ACS の末尾 /Acs が内部実装で固定されており、アプリの実際のルーティングと IdP(Azure AD)に登録する「応答 URL(Reply URL)」が一致しないケースが発生します。結果として、Azure AD 側の検証で「アプリが受けられる URL と一致しない」と判断され、AADSTS50011 となります。
| 発生している事象 | 影響 |
|---|---|
| ライブラリが SAML 認証要求に https://localhost:5001/Saml2/Acs を埋め込み送信 | Azure AD 側の「応答 URL」に登録した /api/Saml2/Acs と不一致で AADSTS50011 により認証失敗 |
SPOptions.ModulePath = "/api/Saml2" を設定しても /Acs の変更は不可 | コントローラ側を合わせても POST は /Saml2/Acs へ届き、API ルートに載らない |
そもそも ACS とは
ACS は IdP からの SAML 応答(SAMLResponse)を SP が受け取る終着点です。Azure AD では「応答 URL(Reply URL)」として登録する値がこれに該当し、スキーム(https)・ホスト名・ポート・パスが完全一致している必要があります。1 文字でも違えば受理されません。
最短の解決策:Sustainsys.Saml2 を v3 へアップグレード(推奨)
v3 では SPOptions.ModulePath の反映範囲が見直され、ベースパス配下の標準エンドポイント(例:Acs)が一貫して生成されるようになりました。したがって、"/api/Saml2" を指定すれば ACS は /api/Saml2/Acs となり、Azure AD 側の「応答 URL」と整合させられます。
NuGet パッケージの変更点
- v2 以前の
Sustainsys.Saml2.AspNetCore2から、v3 ではSustainsys.Saml2に統合。 - .NET 6 以降の最小ホスティングモデルに馴染む登録方法に整理されています。
.NET 6(最小ホスティング)での最小実装例
以下は ACS を /api/Saml2/Acs に設定するサンプルです。実運用ではテナント ID / アプリケーション(エンタープライズアプリ)ごとのメタデータ URL・証明書・クレーム設計に合わせて調整してください。
// Program.cs
using Microsoft.AspNetCore.Authentication.Cookies;
using Sustainsys.Saml2;
using Sustainsys.Saml2.Metadata;
using Sustainsys.Saml2.Configuration;
var builder = WebApplication.CreateBuilder(args);
// 認証(Cookie + SAML2)
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// 挑戦時に SAML へリダイレクトしたい場合は Challenge("Saml2") を明示呼び出し
})
.AddCookie()
.AddSaml2(options =>
{
// ★ 重要:ベースパスを API ルートへ
options.SPOptions.ModulePath = "/api/Saml2";
// SP の EntityID(アプリ ID URI 相当)。一意であれば OK
options.SPOptions.EntityId = new EntityId("https://localhost:5001/sp");
// 必要に応じて戻り先
// options.SPOptions.ReturnUrl = new Uri("https://localhost:5001/");
// Azure AD(Entra ID)の IdP 定義
var tenantId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
var appId = "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"; // エンタープライズアプリのアプリケーション(クライアント)ID
var idp = new IdentityProvider(
new EntityId($"https://sts.windows.net/{tenantId}/"),
options.SPOptions)
{
// Azure AD のフェデレーションメタデータ(エンタープライズアプリ単位)
// 実環境に合わせて設定。動的読み込みを利用するなら LoadMetadata を true に。
MetadataLocation = $"https://login.microsoftonline.com/{tenantId}/federationmetadata/2007-06/federationmetadata.xml?appid={appId}",
LoadMetadata = true,
AllowUnsolicitedAuthnResponse = true // IdP 発イニシエーターを許可する場合
};
options.IdentityProviders.Add(idp);
// 必要に応じて NameID や署名検証などの厳格化
// options.Notifications = new Saml2Notifications { ... };
});
var app = builder.Build();
// 代理(リバースプロキシ)配下の場合のヘッダー処理(Azure App Gateway / Front Door 等)
app.UseForwardedHeaders();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseRouting();
app.UseAuthorization();
// 未認証時に SAML チャレンジを投げる一例
app.MapGet("/secure", (HttpContext ctx) =>
{
if (!ctx.User.Identity?.IsAuthenticated ?? true)
{
return Results.Challenge(
new() { AuthenticationSchemes = new[] { "Saml2" } });
}
return Results.Ok("Signed in");
});
app.Run();
この構成でミドルウェアが生成するメタデータには /api/Saml2/Acs が反映されます。Azure AD(エンタープライズアプリ)の「基本的な SAML 構成」で、下記を 完全一致で登録してください。
| 項目 | 登録例 | 補足 |
|---|---|---|
| 識別子(Entity ID) | https://localhost:5001/sp | SP の EntityId と一致 |
| 応答 URL(Reply URL / ACS) | https://localhost:5001/api/Saml2/Acs | スキーム・ホスト・ポート・パスを完全一致に |
| サインオン URL(任意) | 空でも可 | アプリ側で Challenge するなら必須ではない |
| ログアウト URL(任意) | 必要に応じて | IdP 発の SLO を使う場合のみ |
ミドルウェア順序の注意点
UseAuthentication()はUseRouting()の前後どちらでも動きますが、ポリシー適用の観点ではUseRouting()→UseAuthentication()→UseAuthorization()が定石です。- リバースプロキシ配下では
UseForwardedHeaders()を早い段に入れ、X-Forwarded-ProtoとHostを ASP.NET Core に認識させ、https とポート、ホスト名を正しく復元します。これが狂うとメタデータ/ACS が http になったりポートずれを起こします。
v1 / v2 系を使い続ける場合の代替案(非推奨だが現実解)
長期保守の観点では v3 へのアップグレードが最善です。どうしても v2 を維持せざるを得ない場合、以下の選択肢があります。
| 方式 | 内容 | 注意点 |
|---|---|---|
| ライブラリをフォークして内部の URL 生成を改修 | AuthServicesUrls.cs 等、ACS の末尾固定部を書き換え自前ビルド | メンテの手間が高く、本家追従が難しい |
| URL Rewrite / リバースプロキシ転送 | 受信した /Saml2/Acs をアプリ内部で /api/Saml2/Acs へ 307/308 転送 or 内部ルートへブリッジ | 二重リダイレクトや POST ロスに注意(307/308 を使用) |
| 別ライブラリへ移行(例:ITfoxtec SAML2) | エンドポイントを完全カスタム可能 | API 差分の吸収が必要 |
IIS(URL Rewrite)でのサンプル
<rewrite>
<rules>
<rule name="Saml2AcsToApi" stopProcessing="true">
<match url="^Saml2/Acs$" />
<action type="Redirect" url="/api/Saml2/Acs" redirectType="Permanent" />
</rule>
</rules>
</rewrite>
POST を保ったまま転送するなら 307/308 のステータスを選びます(上の例は恒久リダイレクトの代表例として記載)。
ASP.NET Core ミドルウェアでの内部転送
app.Use(async (ctx, next) =>
{
if (ctx.Request.Path.Equals("/Saml2/Acs", StringComparison.OrdinalIgnoreCase))
{
ctx.Request.Path = "/api/Saml2/Acs"; // 内部書き換え
}
await next();
});
Nginx の例
location = /Saml2/Acs {
return 307 /api/Saml2/Acs;
}
アップグレード時のポイントとチェックリスト
- パッケージ:
Sustainsys.Saml2.AspNetCore2→Sustainsys.Saml2に置換。 - Program.cs:
AddSaml2()のオプション名・拡張点(Notifications等)を見直し。 - ModulePath:
/api/Saml2を設定し、メタデータに/api/Saml2/Acsが出力されることを確認。 - Azure AD の「識別子」「応答 URL」:完全一致(https/ホスト/ポート/パス)。トレーリングスラッシュの有無も含めて統一。
- HTTPS:ローカルは
dotnet dev-certs https --trustで開発証明書を整備。 - リバースプロキシ:
UseForwardedHeadersとForwardedHeadersOptionsを正しく設定。X-Forwarded-Proto=httpsを必ず送る。 - メタデータ更新:Azure AD のフェデレーションメタデータを読み込み直し(証明書ロールオーバーにも備える)。
- クレーム設計:NameID、email、roles 等が想定どおりに受け取れているか。
AADSTS50011 を素早く潰すための原因別チェック表
| 症状 | 主因 | 対処 |
|---|---|---|
| URL は合っているはずなのに失敗 | ホストヘッダー / X-Forwarded-Proto により http で自己生成 | 逆プロキシ設定を見直し、https と Host を正しく復元 |
| ローカルは成功、本番だけ失敗 | ポート・PathBase の不一致 | app.UsePathBase() の有無、環境別 URL を確認 |
| たまにだけ失敗する | 複数ノード間で設定差異、古いメタデータのキャッシュ | 全ノードの設定同期、メタデータ更新・アプリ再起動 |
| POST が別 URL に飛ぶ | 302 リダイレクトでメソッドが GET に変わる | 307/308 を使用、もしくは内部書き換え |
| マルチテナントでのみ失敗 | Issuer / Audience の検証とテナント境界 | 単一テナント固定か、テナント別 IdP 設定に分離 |
ログとトレースで“どこがズレたか”を可視化する
ライブラリのログカテゴリを Debug 以上にして、SAMLRequest / SAMLResponse のフローを追うのが最短です。URL 生成の段階で既に /Saml2/Acs 固定になっていないか、RelayState が想定どおりか、署名検証に失敗していないかを確認します。
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Sustainsys.Saml2": "Debug",
"Microsoft.AspNetCore.Authentication": "Debug"
}
}
}
別ライブラリ(ITfoxtec SAML2 など)への移行検討ポイント
- エンドポイントを自由に決めたい、または SP メタデータを細かく制御したい場合は選択肢になりえます。
- その一方で、移行工数(クレームマッピング、例外種別、設定キー、ミドルウェア差異)が必ず発生します。短期で確実に直したいだけなら v3 へのアップグレードの方が定石です。
セキュリティと運用の実務注意点
- 署名検証の厳格化:受信 XML の署名アルゴリズム・証明書サムプリントを固定し、無署名応答は拒否。
- Clock Skew:環境差を見込んだ許容範囲(数分)を設定。時刻同期(NTP)は必須。
- 再利用攻撃対策:InResponseTo・NotOnOrAfter の検証を有効に。
- 証明書ロールオーバー:Azure AD は自動更新されるため、メタデータの定期再読込か複数鍵の許容を設計。
- ログ最小化:個人情報を含むクレーム値の過剰ログ出力は避ける。
導入前後でのベンチマーク・回帰テスト観点
- 匿名→Challenge→ACS 受信→クレーム抽出→Cookie 発行の一連を E2E テストし、302/307/308 の扱いと HTTP メソッドの変化を観測。
- リバースプロキシ配下・直結・HTTP/2・HTTP/1.1 の各パターンでホスト/スキームが崩れないか。
- 複数 Reply URL 登録(本番/ステージング/開発)時の取り違えがないか。
「API らしい URL にしたい」要件のある組織で最適解が v3 となる理由
API 基盤では、認証関連のエンドポイントも /api/.. に統一したい要望がよくあります。v3 は ModulePath の挙動が素直で、アプリのルーティング設計・API の命名規約・逆プロキシのパスルールと自然に噛み合います。将来の保守や新規メンバー参画時の学習コストも低く、「公式サポートされた設定だけで実現できる」ことは障害対応の速さに直結します。
メリット・デメリットの比較(再掲)
| 選択肢 | メリット | デメリット |
|---|---|---|
| v3 へアップグレード(推奨) | 公式サポートでフルカスタム可/保守性が高い | 移行・検証作業が必要 |
| v2 をフォーク改修 | 現行コードの変更最小 | 永続的な自前メンテが発生 |
| URL Rewrite | ライブラリ変更不要・即応可能 | 構成の複雑化/将来負債化のリスク |
| 別ライブラリへ乗り換え | 柔軟性が高くエンドポイント自由 | API 差分の吸収コスト |
まとめ:迷ったら「v3 + ModulePath」一択
結論:Sustainsys.Saml2 v2.x では ACS パスの完全変更はできません。公式にサポートされた方法で要件を満たすには v3 へアップグレードし、SPOptions.ModulePath = "/api/Saml2" を設定するのが最もシンプルで、将来の保守性も高い解です。Azure AD 側では「識別子」「応答 URL」をアプリ側と完全一致に揃える――これだけで AADSTS50011 に悩まされる時間から解放されます。
付録:完全版 Program.cs(参考)
// Program.cs(参考フル例)
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;
using Sustainsys.Saml2;
using Sustainsys.Saml2.Metadata;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(o =>
{
o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.LoginPath = "/login"; // 任意
options.LogoutPath = "/logout"; // 任意
})
.AddSaml2(options =>
{
options.SPOptions.ModulePath = "/api/Saml2";
options.SPOptions.EntityId = new EntityId("[https://sample.example.com/sp](https://sample.example.com/sp)");
// より厳密に戻り先を固定したい場合
// options.SPOptions.ReturnUrl = new Uri("https://sample.example.com/");
var tenantId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
var appId = "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy";
var idp = new IdentityProvider(new EntityId($"https://sts.windows.net/{tenantId}/"), options.SPOptions)
{
MetadataLocation = $"https://login.microsoftonline.com/{tenantId}/federationmetadata/2007-06/federationmetadata.xml?appid={appId}",
LoadMetadata = true,
AllowUnsolicitedAuthnResponse = true
};
options.IdentityProviders.Add(idp);
// クレームマッピングやエラー処理などの通知を必要に応じて追加
// options.Notifications = new Saml2Notifications { ... };
});
var app = builder.Build();
// 逆プロキシ配下対応(必要に応じて)
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost
});
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
// SAML サインイン誘導
app.MapGet("/login", () => Results.Challenge(new() { AuthenticationSchemes = new[] { "Saml2" } }));
// 保護された API の例
app.MapGet("/api/values", (HttpContext ctx) =>
{
if (!ctx.User.Identity?.IsAuthenticated ?? true)
{
return Results.Challenge(new() { AuthenticationSchemes = new[] { "Saml2" } });
}
return Results.Json(new[] { "one", "two", "three" });
});
app.Run();
付録:IIS/Nginx/アプリ内のリライト比較(v2 継続運用向け)
| 方式 | 設定例 | 利点 | 留意点 |
|---|---|---|---|
| IIS URL Rewrite | web.config の <rewrite> ルール | GUI で管理可・既存 IaaS に馴染む | POST を維持するには 307/308 指定 |
| Nginx | return 307 /api/Saml2/Acs; | 軽量・高速 | 設定ミスでリダイレクトループの恐れ |
| アプリ内書き換え | ctx.Request.Path = "/api/Saml2/Acs" | 外部依存なし・最小構成 | アプリコードに運用ロジックが混在 |
最終結論
Sustainsys.Saml2 v2.x では ACS パスの完全変更は不可能。公式サポートされた方法で要件を満たすには v3 へアップグレードして
SPOptions.ModulePathを設定するのが最もシンプルで保守性も高い。

コメント