SAMLでSP-Initiated SSOのIdPを作成して学んだこと
ソリューションセクションの由良です。今回は外部システムとのシングルサインオン(SSO)を、独自ID/パスワード方式からSAML 2.0方式へ移行する機会がありました。しかも既製のIdP製品を使わず、アプリケーション側でIdP(Identity Provider)を自作するという構成です。本記事は、その過程で学んだSAMLの用語・設計・セキュリティ対策を、自分向けに整理したものです。
1. なぜSAMLへ移行するのか
移行前は「独自ID/パスワードをPOSTで渡す」レガシーなSSO方式でした。この方式には構造的な負債があります。連携先にパスワードそのものを渡す必要があるため、パスワードを不可逆なハッシュ(bcrypt等)ではなく、復号可能な可逆暗号で保存せざるを得ないケースが生まれます。これはセキュリティ上の大きな懸念です。
SAMLはこの問題を根本から解決します。SAMLでは「パスワードを渡す」のではなく、「このユーザーは認証済みである」という署名付きの証明(Assertion)を渡すだけです。連携先にパスワードを一切渡さなくなるため、パスワードを本来の不可逆ハッシュに戻せるようになります。
2. 登場人物と用語
SAMLは「信頼できる主体が『このユーザーは認証済みだ』と署名付きで宣言し、別のシステムがそれを検証してログインさせる」仕組みです。まず用語を押さえましょう。
| 用語 | 意味 | 本ケースでの担当 |
|---|---|---|
| IdP(Identity Provider) | 認証を行い、認証証明を発行・署名する側 | 自作アプリケーション |
| SP(Service Provider) | サービス本体。IdPの証明を検証してログインさせる側 | 外部システム |
| SP-Initiated SSO | SP側からフローが始まる方式 | 本ケースはこれ |
| AuthnRequest(SAMLRequest) | SP→IdPへの「このユーザーを認証して」という要求XML | SPが生成/IdPが検証 |
| SAMLResponse | IdP→SPへの認証結果XML。中にAssertionを含む | IdPが生成・署名 |
| Assertion | 「誰が」「いつ」「どんな方法で」認証されたかの中核データ | Response内に埋込 |
| ACS(Assertion Consumer Service) | SP側でSAMLResponseを受け取るURL | SPが提供 |
| Binding | メッセージの運搬方法。HTTP-POST(フォーム自動送信)かHTTP-Redirect | HTTP-POSTを採用 |
| NameID | ユーザー識別子(Subject)。本ケースはメールアドレス | ユーザーのメール |
| Audience | この証明の宛先を限定する値(=SPの識別子) | SPのEntity ID |
| InResponseTo | どの要求への応答かを示す紐付けID | AuthnRequestのID |
| RelayState | SPがSSO後に戻りたい状態を保持する不透明値。IdPは中身を変えず透過するだけ | そのまま転送 |
| sign_request | SPがAuthnRequestを自分の秘密鍵で署名する設定 | 有効化 |
| fingerprint | 証明書のハッシュ値。証明書本体の代わりに登録できる短い形式 | SHA-256等 |
信頼の方向(証明書が2系統になる)
- IdP→SP:IdPがIdP秘密鍵でSAMLResponseに署名 → SPがIdP公開鍵/fingerprintで検証
- SP→IdP:SPがSP秘密鍵でAuthnRequestに署名 → IdPがSP公開鍵で検証(sign_request有効化時)
このように署名・検証の鍵が双方向で登場するため、証明書は2系統を管理することになります。IdP鍵は自己署名で発行でき(SAMLの信頼はSP側の事前登録で確立するため、公的CAは必須ではない)、SP証明書は連携先から受領して設定します。
3. 全体フロー(ハイブリッド型)
採用したのは「アプリ内のマイページを起点とし、最後の遷移だけSAML化する」ハイブリッド型です。
① ユーザーがボタンをクリック ② 事前チェック・ユーザー同期のAPIを実行(既存処理を流用) ③ ブラウザを外部システムのトップURLへ遷移 ④ 外部システム(SP)が未認証を検知 → AuthnRequestを生成・署名 → IdPの/ssoへ ⑤ IdP: リクエスト検証 → SAMLResponse生成・署名 → auto-submitフォームで返却 ⑥ ブラウザがSAMLResponseを外部システムのACSへPOST ⑦ 外部システム(SP)が検証 → ログインセッション確立 → 利用画面へ
この構成の利点は、SAMLフローに入る前に必ずユーザー情報の同期処理が走るため、アプリ側と外部システム側のユーザー情報が常に一致することです。既存資産を活かしつつ、認証の最後の一手だけをSAMLに置き換えられます。
4. IdPが解くべき技術課題
SPからのcross-siteなPOSTで、IdPが「ログイン済みセッション」を確実に取得するには、いくつかの障害を越える必要があります。
| 課題 | 対策 |
|---|---|
| cross-site POSTにセッションCookieが乗らない(SameSite=Lax) | SameSite=None + Secure に設定 |
| SPからのPOSTにCSRFトークンが無く弾かれる | SSOエンドポイントをCSRF除外。代わりにSP署名検証で正当性を担保 |
| 遷移中にセッションが切れた場合 | リクエストを一時退避し、再ログイン後に復帰させる |
| 退会・無効ユーザー | 状態フラグ/論理削除をチェックして拒否 |
特に重要なのが2つ目の設計思想です。CSRF保護を外す代わりに、SP署名検証がより強い保証を与えます。CSRFはブラウザの自動Cookie送信を悪用した偽リクエストを防ぐ仕組みですが、SP署名は「連携先しか持ち得ない秘密鍵による正当性証明」です。SAMLフローでは後者が上位互換の防御になります。
5. 受信リクエスト検証のセキュリティ(本記事の核心)
IdPのSSOエンドポイントは「CSRF保護を外した、認証なしで叩ける公開エンドポイント」です。ここに連携先を騙る攻撃者が細工したリクエストを投げられます。検証が甘いと、攻撃者が任意ユーザーとしてログインするための証明を、正規のIdPに発行させられます。SAMLはXML+署名という性質上、署名を回避・すり抜ける攻撃が歴史的に多く知られており、体系的な防御が不可欠です。
5-1. XXE(XML外部実体)攻撃対策
攻撃:XMLに外部実体宣言を仕込み、サーバのファイルを読ませたりSSRFを起こす古典的攻撃。
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<AuthnRequest>...&xxe;...</AuthnRequest>
防御(二重):(1) DOCTYPE/ENTITY宣言を含むXMLは事前に拒否する(正当なリクエストはDTDを必要としないため、あれば攻撃と断定できる)。(2) パーサ設定で外部実体を「展開する」フラグを絶対に付けず、ネットワーク取得も抑止する。多くのXXE脆弱性は、名前に反して実体を展開してしまうフラグを誤って付けたことが原因です。「寛容なパーサはセキュリティの敵」という典型例です。
5-2. 署名検証とXSW(XML Signature Wrapping)
キー差し替え攻撃:攻撃者が自分の鍵で署名し、その公開鍵をXML内のKeyInfoに同梱する。素朴な実装は「XMLに入っている鍵で検証」してしまい、任意の署名が通ってしまいます。
防御:受信XML内の鍵は一切信用せず、事前に設定した連携先の証明書だけで検証します。
XSW(署名ラッピング):SAML最大の攻撃クラスです。「正規に署名された要素」と「攻撃者が差し込んだ偽要素」を1つのXMLに同居させ、署名検証は正規要素を見るが、業務ロジックは偽要素を読むというズレを突きます。
<Response>
<AuthnRequest ID="evil"> ← アプリが読む(攻撃者の偽データ)
<AuthnRequest ID="legit"> ← 署名はこちらを検証してOKと判定
<Signature URI="#legit"/>
</Response>
防御は三段構え:
- 署名の位置:Signatureはルート要素の直下にあることを必須にする。
- 参照の静的検査:署名検証の前に構造を検査する。参照は1件のみ/参照URIがルート要素のIDと完全一致/変換(Transform)が許可リスト(enveloped-signatureと正規化のみ)で、XPath/XSLTのような部分抽出を拒否する。XSWの亜種には「Transformに細工して署名対象を都合よく絞り込む」手口があるため、これが肝です。
- 検証結果の答え合わせ:署名検証後、ライブラリが実際に検証したノードがルート要素そのもので、そのIDが期待値と一致することを自前で再確認する。ライブラリ実装のクセに依存しない確実性を得ます。
5-3. Destination検証(リクエスト横流し対策)
攻撃:別環境向けに正規発行された署名済みリクエストを、本番エンドポイントに投げ込む(署名自体は正しいので通ってしまう)。
防御:リクエストのDestination属性が、自分のSSOエンドポイントURLと一致するかを確認する。設定漏れ時は「通す」ではなく「拒否」するfail-closed設計にします。
5-4. タイムスタンプの鮮度検証
攻撃:リプレイ検知のキャッシュ有効期限が切れた後、古い署名済みリクエストを再送する。
防御:リクエストの発行時刻が現在時刻から許容範囲内であることを必須にする。ここでも寛容な日時パース関数は使わず、タイムゾーンを厳格に固定してパースします。曖昧な日時解釈は判定をすり抜ける穴になるためです。
5-5. ホワイトリスト検証
攻撃:IssuerやACS URLを攻撃者のものに書き換え、認証結果を攻撃者サイトへ送らせる(証明の窃取)。
防御:発行元(Issuer)と送信先(ACS URL)を、設定で固定した正規の値と完全一致で照合する。これにより認証結果の送り先は正規のSPだけに限定されます。
5-6. リプレイ検知(アトミック性が重要)
攻撃:一度盗聴した正規リクエストをそのまま再送する。
防御:リクエストIDをキャッシュに記録し、重複を拒否する。ここで学びが2つあります。
- 「存在確認 → 書き込み」の2操作ではなく、「未存在なら書く」をアトミックな1操作で行う。前者は確認と書き込みの隙に別リクエストが割り込むレースコンディションがあり、同時二重送信を許してしまう。
- アトミックな操作を保証できないキャッシュ構成では、起動時に例外で停止(fail-closed)する。本番では共有かつアトミックなストア(Redis等)を必須とする。
6. SAMLResponseの生成・署名・返却
検証をすべて通過したら、IdPは応答を作って返します。ここで最も重要な設計判断は、「誰としてログインさせるか」をリクエストからではなく、自分が握っているログイン済みセッションから取ることです。攻撃者がリクエストをどう細工しても、なりすましはここで根本的に防がれます。
6-1. Assertionの各要素とその意味
SAMLResponseの各要素は「SP側でどの検証に使われるか」と対応づけて読むと理解が進みます。
Response ├─ InResponseTo = リクエストのID → SPが「自分の要求への応答か」を照合 ├─ Issuer = IdP識別子 → SPが「信頼するIdPからか」を判定 └─ Assertion(認証証明の本体) ├─ Subject │ ├─ NameID = ユーザーのメール → SPがユーザーを特定 │ └─ SubjectConfirmationData │ ├─ NotOnOrAfter → 証明の有効期限 │ ├─ Recipient = ACS URL → 受け取ってよいのは誰か │ └─ InResponseTo → 要求との紐付け ├─ Conditions(NotBefore / NotOnOrAfter) → Assertion自体の有効期間 │ └─ AudienceRestriction → 「このSP宛」に限定 └─ AuthnStatement(認証コンテキスト)
6-2. 短命なAssertionと出力エスケープ
- 有効期限を短く(数分)保つ:証明が盗まれても短時間で無効になる。開始時刻には数十秒の猶予を持たせ、IdPとSPの時計のずれ(クロックスキュー)を吸収する。
- 出力も信用しない:XMLに埋め込む動的値(メールアドレス等)はすべてXML用にエスケープする。もし値にタグ的な文字列が混ざると、XML構造を破壊・注入できてしまう。受信XMLを信用しないのと同様に、送信XMLも安全に組み立てる。
- IDは暗号論的乱数で生成:予測可能な乱数を避ける。
6-3. 署名と返却
組み立てたXMLをIdP秘密鍵で署名します(RSA-SHA256)。IdP向けのResponseビルダーを持たないライブラリでも、XMLは手書きし、署名機能だけライブラリから借りるというハイブリッド構成で対応できます。既存ライブラリを適材適所で使う発想です。
返却はauto-submitフォームで行います。署名済みXMLをBase64化し、onloadで自動送信するHTMLフォームをブラウザに返すと、ブラウザが即座にSPのACSへPOSTします。これがHTTP-POST bindingの実体です。RelayStateは中身を変えず透過し、フォームの属性値も必ずエスケープします。
7. SP側のログインと「片道フロー」の非対称性
ブラウザがSAMLResponseをSPのACSへPOSTした瞬間、IdP(自作アプリ)の仕事は終わりです。あとはSPが自分の責任でログイン処理をします。IdPが受信時に行った検証(署名検証・宛先確認・鮮度確認)を、今度はSPがSAMLResponseに対して行う――完全に対称の関係です。
ここで見落としがちな重要点があります。SAMLのSSOは片道フローで、SP側でログインが成功してもIdP側には通知が返ってこないということです。レガシーSSOではログイン時にIdPへコールバックが来ることもありましたが、SAMLではIdPの責務は「署名付き証明を返すこと」で完結します。
そのため、ログイン後の状態(例:利用状況・完了ステータス)をアプリ側で把握したい場合は、SPからのPUSH通知に頼るのではなく、アプリ側から定期的にAPIをPULLして同期するといった別レイヤーの仕組みが必要になります。「SSOは片道、状態把握は別途」という非対称性は、レガシーからSAMLへの移行で最も設計に影響した点でした。
8. 設計から得た普遍的な原則
- 入力を信用しない:XML内の鍵(KeyInfo)も、寛容なパーサも信用しない。
- fail-closed:設定漏れ・不明な状態は「通す」ではなく「拒否」する。
- 多層防御:XSWは三段、XXEは二段。1つ破られても次で止める。
- アトミック性:レースコンディションを設計で構造的に排除する。
- 答え合わせ:ライブラリの検証結果を自前でも確認する。
- 信頼の源はセッション:なりすまし防止は「ユーザーをリクエストではなくセッションから特定する」の一点に集約される。
- 出力も安全に:受信だけでなく、生成するXMLもエスケープして安全に組む。
IdPを作成するにあたってSAMLの各要素が「なぜ存在し、どの攻撃を防ぐのか」を理解するのは大変でした。なお原稿は自分向けのメモとして整理したものであり、貼り付けのための生成はAIによる補助を受けています。




