取引先から「うちのEntra IDでログインさせてほしい」と言われたとき、自社のSaaSや業務システムがSP(サービスプロバイダ)としてSAML連携を受けるには、IdPの画面設定より先に、SP側の作りを決める必要があります。この記事では、IdP管理者と受け渡す値、python3-saml・node-saml・ruby-samlの版と脆弱性勧告、テナントごとのIdP設定とURLの分け方、JITプロビジョニングで利用者を紐付けるキーを、動くコードつきで解説します。最後に判断するのは、SAML連携を標準機能にするかどうかと、SSO強制の運用方針です。
まとめ:SAML連携はテナントごとのURLとIdP設定と利用者の紐付けキーで決まる
複数の取引先とつなぐSaaSでは、SPのentityIDとACSのURLをテナントごとに分け、どのIdP設定で検証するかをURLで決めます。応答の中身を見てからテナントを選ぶ作りにはしません。
利用者はテナント、IdPのentityID、NameIDの3つの組で引きます。メールアドレスだけで既存アカウントへ自動で紐付けると、別テナントのIdP管理者が他社の社員になりすませます。メールで照合する場合の対象は、そのテナントが所有を検証したドメインだけです。
ライブラリは版と勧告を見て選び、更新を止めない体制を先に作ります。SSO強制は、パスワードで入れる緊急用の管理者を残してから有効にします。
SAML連携で自社SaaSがSP側として用意する値とIdP管理者との受け渡し
SAMLの仕組みとSP-InitiatedのフローはSAMLの基本記事SAMLとは?認証フロー・IdP/SPの仕組みとOAuth・OIDCとの使い分けを実装視点で解説にまとめています。ここでは、取引先が増えるSaaSの立場で、どの値を誰がどこに入れるかを整理します。
SPとIdPが互いの画面に入力し合う7つの値と入力する側の対応表
SAML連携の設定作業は、SPが出す値をIdP管理者に渡し、IdPが出す値をSPに登録する往復です。値はOASIS SAML 2.0 Metadata仕様のEntityDescriptorに1つのXMLとしてまとめられます。
| 値 | 出す側 | 入れる先 | テナント別にするか |
|---|---|---|---|
| SPのentityID | SP | IdPの識別子欄 | 分ける |
| ACSのURL | SP | IdPの応答URL欄 | 分ける |
| NameIDの形式 | SPが指定 | IdPの名前識別子欄 | 共通でよい |
| 送ってほしい属性名 | SPが指定 | IdPの属性とクレーム欄 | 共通でよい |
| IdPのentityID | IdP | SPのテナント設定 | テナントごと |
| IdPのSSOのURL | IdP | SPのテナント設定 | テナントごと |
| IdPの署名証明書 | IdP | SPのテナント設定 | テナントごと |
実務では、SPのメタデータXMLをテナントごとのURLで配り、IdP側のメタデータXMLを受け取って取り込む形にすると、手入力の打ち間違いが減ります。メタデータの要素の読み方はSAMLメタデータとは:EntityDescriptorの読み方と署名証明書の更新手順で扱っています。
Entra IDが既定で送るNameIDのUPNと属性名のURIの読み替え
取引先のIdPはEntra IDが多く、その既定値に合わせておくと設定の往復が減ります。Microsoft LearnのCustomize SAML token claimsによると、Entra IDは既定でNameIDに利用者のユーザープリンシパル名(UPN)を入れ、メール、名、姓の属性も付けて送ります。
属性名はURIです。SAML 2.0 token claims referenceの例では、名はhttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname、変わらない利用者IDはhttp://schemas.microsoft.com/identity/claims/objectidentifier、テナントIDはhttp://schemas.microsoft.com/identity/claims/tenantidで届きます。
UPNは改姓や組織変更で変わります。利用者の照合キーにするなら、取引先に頼んでNameIDの形式をPersistentにしてもらうか、objectidentifierを属性で受けます。Okta、Google Workspace、HENNGE Oneなど他のIdPは属性名が別なので、属性名の読み替え表をテナント設定に持たせると、IdPごとのコード分岐が要りません。
SAML連携のライブラリをPython・Node.js・Rubyの版と勧告で比べる基準
SAMLの署名検証は、XMLの正規化と名前空間の扱いが絡むため、自前で書くと事故になります。ライブラリに任せることは前提として、その版と保守の状況を選定基準にします。
python3-samlなど主要4ライブラリの最新版と更新日の比較
2026年11月5日に各レジストリのAPIで取得した最新版です。
| ライブラリ | 言語 | 最新版 | 公開日 |
|---|---|---|---|
| python3-saml | Python | 1.16.0 | 2023-10-09 |
| pysaml2 | Python | 7.5.5 | 2026-09-17 |
| @node-saml/node-saml | Node.js | 5.2.0 | 2026-10-26 |
| ruby-saml | Ruby | 1.18.1 | 2025-07-29 |
python3-samlは最新版が2023年で止まっていますが、設定が辞書1つで済み、テナント別の設定を作りやすい作りです。署名検証はxmlsecに任せており、xmlsecは1.3.18が2026年10月29日に出ています。Pythonで新規に組むなら、設定のしやすさでpython3-samlを選び、依存のxmlsecとlxmlの更新を追う運用にします。PassportやExpressの既存アプリならnode-saml、Railsならruby-samlです。
2024年以降に続いた署名検証バイパスの勧告と更新を止めない運用
SAMLライブラリの脆弱性は、認証そのものを素通りさせる重大度criticalが続いています。ruby-samlでは2025年3月に、DOCTYPEの扱いで2つのXMLパーサの解釈がずれて認証を回避されるCVE-2025-25291が公開され、1.18.0と1.12.4で修正されました。node-samlでも2025年7月に署名検証の不備CVE-2025-54369が公開され、5.0.1以前が対象、5.1.0で修正されています。
SAML連携を入れたSaaSは、取引先の全社員の入口を預かります。依存更新の自動PRを有効にし、SAMLライブラリと署名検証の依存(xmlsec、libxml2)は、勧告が出たら数日以内に本番へ出せる体制を、機能の公開前に用意します。この体制が組めない場合の選択肢は、次に説明するブローカー方式です。
SP実装を自前で持たずKeycloakやCognito・WorkOSへ寄せる条件
SAMLの受け口を、Keycloak、Amazon Cognito、WorkOSなどの認証基盤に任せ、自社アプリはOIDCでそこから受け取る構成もあります。アプリ側はSAMLのXMLに触れずに済み、取引先ごとのIdP登録も基盤の管理画面で行えます。
寄せるべきなのは、取引先が10社を超える見込みがある、OIDCの取引先も同時に受けたい、署名検証の依存更新を自社で回す人がいない、のいずれかに当てはまる場合です。Cognitoを中継にする手順はCognito SAML連携の手順:Entra IDでSSOを組む設定と属性マッピング、接続数課金で外部化する選択肢はWorkOSとは:SSOとSCIMを外部化するB2B SaaS向け認証基盤を実装視点で解説で比べられます。取引先が数社で増える予定が無く、アプリが既にPythonで組まれているなら、次の章の自前実装で足ります。
テナント別のIdP設定をDBに持ちpython3-samlでSAML連携を組む手順
ここからは、テナントごとにIdP設定をDBへ保存し、リクエストのURLから設定を組み立てる実装を示します。単一のIdPとつなぐ基本の設定ファイルは13299の記事を参照してください。
python3-samlとxmlsecの導入とテナントごとに分けるURLの設計
python3-samlはxmlsecとlxmlに依存します。python3-samlのREADMEは、xmlsecとlxmlのlibxml2の版の食い違いを避けるため、lxmlをバイナリでなくソースから入れるよう勧めています。Debian系の例です。
sudo apt-get install -y libxml2-dev libxmlsec1-dev libxmlsec1-openssl
python -m pip install "python3-saml==1.16.0" flask
python -m pip install --force-reinstall --no-binary lxml lxml
URLは/saml/{テナントID}/metadata、/saml/{テナントID}/login、/saml/{テナントID}/acsのようにテナントIDをパスに入れます。SPのentityIDもテナントごとに変えると、1つの取引先が本番と検証の2テナントを持っても、IdP側で別のアプリとして登録できます。ACSのURLでテナントが決まるため、届いた応答をどの証明書で検証するかが応答の中身に左右されません。
テナント表と検証済みドメイン表から設定辞書と利用者を作るコード
次のコードは標準ライブラリだけで動き、テナント別の設定辞書の組み立てと、JITでの利用者の作成と照合を試せます。テーブルは3つで、テナントごとのIdP設定、テナントが所有を確かめたメールドメイン、利用者です。証明書は新旧2枚を区切りで持ち、python3-samlのx509certMultiに渡します。
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE tenants (
id TEXT PRIMARY KEY,
idp_entity_id TEXT NOT NULL,
idp_sso_url TEXT NOT NULL,
idp_certs TEXT NOT NULL,
default_role TEXT NOT NULL DEFAULT 'member',
sso_enforced INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE tenant_domains (
domain TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
verified_at TEXT
);
CREATE TABLE users (
id INTEGER PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
idp_entity_id TEXT NOT NULL,
name_id TEXT NOT NULL,
email TEXT NOT NULL,
role TEXT NOT NULL,
UNIQUE (tenant_id, idp_entity_id, name_id)
);
""")
BASE = "https://app.example.jp"
EMAIL = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
def build_settings(tenant_id):
"""テナントのURLからpython3-samlに渡す設定辞書を組み立てる"""
t = db.execute("SELECT * FROM tenants WHERE id = ?", (tenant_id,)).fetchone()
if t is None:
raise LookupError("unknown tenant")
_, idp_entity_id, sso_url, certs, _, _ = t
return {
"strict": True,
"sp": {
"entityId": f"{BASE}/saml/{tenant_id}/metadata",
"assertionConsumerService": {
"url": f"{BASE}/saml/{tenant_id}/acs",
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
},
"NameIDFormat": "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
},
"idp": {
"entityId": idp_entity_id,
"singleSignOnService": {
"url": sso_url,
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
},
# 証明書更新の期間は新旧2枚を並べる
"x509certMulti": {"signing": certs.split("|")},
},
"security": {
"wantAssertionsSigned": True,
"requestedAuthnContext": False,
"rejectDeprecatedAlgorithm": True,
},
}
def jit_upsert(tenant_id, idp_entity_id, name_id, attrs):
"""検証済みのアサーションから利用者を引き、無ければ作る"""
email = attrs.get(EMAIL, [""])[0].lower()
domain = email.rpartition("@")[2]
owner = db.execute(
"SELECT tenant_id FROM tenant_domains WHERE domain = ? AND verified_at IS NOT NULL",
(domain,),
).fetchone()
if owner is None or owner[0] != tenant_id:
return None, f"拒否: {email} は {tenant_id} の検証済みドメインではない"
row = db.execute(
"SELECT id, role FROM users WHERE tenant_id = ? AND idp_entity_id = ? AND name_id = ?",
(tenant_id, idp_entity_id, name_id),
).fetchone()
if row:
db.execute("UPDATE users SET email = ? WHERE id = ?", (email, row[0]))
return row[0], f"既存 user_id={row[0]} role={row[1]}"
role = db.execute("SELECT default_role FROM tenants WHERE id = ?", (tenant_id,)).fetchone()[0]
cur = db.execute(
"INSERT INTO users (tenant_id, idp_entity_id, name_id, email, role) VALUES (?, ?, ?, ?, ?)",
(tenant_id, idp_entity_id, name_id, email, role),
)
return cur.lastrowid, f"新規 user_id={cur.lastrowid} role={role}"
IDP_A = "https://sts.windows.net/aaaabbbb-0000-cccc-1111-dddd2222eeee/"
IDP_B = "https://idp.example.org/saml"
db.execute("INSERT INTO tenants VALUES ('acme', ?, 'https://login.microsoftonline.com/x/saml2', 'CERT_OLD|CERT_NEW', 'member', 1)", (IDP_A,))
db.execute("INSERT INTO tenants VALUES ('beta', ?, 'https://idp.example.org/sso', 'CERT_B', 'viewer', 0)", (IDP_B,))
db.execute("INSERT INTO tenant_domains VALUES ('acme.co.jp', 'acme', '2026-11-01')")
db.execute("INSERT INTO tenant_domains VALUES ('beta.jp', 'beta', '2026-11-02')")
s = build_settings("acme")
print(s["sp"]["assertionConsumerService"]["url"])
print(s["idp"]["x509certMulti"]["signing"])
print(*jit_upsert("acme", IDP_A, "m_H3naDei2LN", {EMAIL: ["[email protected]"]}))
print(*jit_upsert("acme", IDP_A, "m_H3naDei2LN", {EMAIL: ["[email protected]"]}))
print(*jit_upsert("beta", IDP_B, "attacker-01", {EMAIL: ["[email protected]"]}))
Python 3.14.10で実行した出力です。1回目で利用者が作られ、2回目は同じ利用者に当たります。3回目は、テナントbetaのIdPがacmeの社員のメールを主張したケースで、拒否されます。
https://app.example.jp/saml/acme/acs
['CERT_OLD', 'CERT_NEW']
1 新規 user_id=1 role=member
1 既存 user_id=1 role=member
None 拒否: [email protected] は beta の検証済みドメインではない
設定ではstrictをtrue、wantAssertionsSignedをtrueにしています。python3-samlのwantAssertionsSignedの既定はfalseで、アサーション単体の署名を求めない設定になっているためです。requestedAuthnContextをfalseにするのは、既定のPasswordProtectedTransportの要求がEntra IDで認証失敗を招くことがあるからです。
FlaskのACSで要求IDとIssuerと署名を照合してログインさせる処理
前のコードをtenant_saml.pyとして保存し、Flaskからログインの開始とACSを受けます。python3-samlとxmlsecの導入が必要です。
from flask import Flask, abort, redirect, request, session
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from tenant_saml import build_settings, jit_upsert # 前掲のコードを tenant_saml.py として保存
app = Flask(__name__)
app.secret_key = "replace-with-random-secret"
# IdPからのクロスサイトPOSTでもセッションCookieを送らせる
app.config.update(SESSION_COOKIE_SAMESITE="None", SESSION_COOKIE_SECURE=True)
def saml_request_data(r):
return {
"https": "on",
"http_host": r.host,
"script_name": r.path,
"get_data": r.args.copy(),
"post_data": r.form.copy(),
}
def tenant_auth(tenant_id):
try:
settings = build_settings(tenant_id)
except LookupError:
abort(404)
return OneLogin_Saml2_Auth(saml_request_data(request), settings)
@app.get("/saml/<tenant_id>/login")
def saml_login(tenant_id):
auth = tenant_auth(tenant_id)
url = auth.login(return_to="/")
session["saml_pending"] = {"tenant": tenant_id, "request_id": auth.get_last_request_id()}
return redirect(url)
@app.post("/saml/<tenant_id>/acs")
def saml_acs(tenant_id):
pending = session.pop("saml_pending", None)
if not pending or pending["tenant"] != tenant_id:
abort(400) # 自分が送った要求に対応しない応答は受けない
auth = tenant_auth(tenant_id)
auth.process_response(request_id=pending["request_id"])
if auth.get_errors() or not auth.is_authenticated():
app.logger.warning("SAML検証失敗: %s", auth.get_errors())
abort(403)
idp_entity_id = auth.get_settings().get_idp_data()["entityId"]
user_id, message = jit_upsert(tenant_id, idp_entity_id, auth.get_nameid(), auth.get_attributes())
if user_id is None:
abort(403)
session.clear()
session["user_id"] = user_id
return redirect("/")
process_responseにrequest_idを渡す点が要です。python3-samlのresponse.pyは、strictのときでもrequest_idが渡されたときだけInResponseToを照合します。同じstrictの検証で、応答とアサーションのIssuerがテナント設定のIdPのentityIDと一致するか、Audienceが自社のentityIDかも確かめます。
IdPからのACSへのPOSTはクロスサイトの送信になるため、セッションCookieのSameSiteをNoneにしないと要求IDが読めません。符号化とSameSiteの関係はSAML HTTP POSTバインディングとは:Redirect・Artifactとの違い、要求を伴わないIdP起点の応答を受けない理由はSAML IdP-initiatedとは:SP-initiatedとの違いとACS実装の判断に詳しく書いています。
顧客が入力するIdPメタデータURLの取得で起きるSSRFと防ぎ方
テナントの管理者にIdPのメタデータURLを入力させ、OneLogin_Saml2_IdPMetadataParser.parse_remoteで取り込む画面はよく作られます。python3-samlのREADMEは、このクラスが入力されたURLを一切検証しないと明記し、SaaSで利用者に入力を任せる場合は追加の対策を求めています。
対策は3つです。httpsだけを許可すること、名前解決した先がプライベートアドレスやリンクローカル(クラウドのメタデータの169.254.169.254を含む)なら取得しないこと、取得はタイムアウトを付けて1回だけにすることです。取得したXMLは即座に本番の設定へ入れず、entityIDと証明書の指紋を管理画面で確認させてから保存します。
JITプロビジョニングで利用者を作るときの紐付けキーと権限の決め方
JITプロビジョニングは、SAMLで初めてログインした利用者を、その場でSP側に作る方式です。作る処理は短いのですが、誰と紐付けるかを誤ると、SAML連携そのものが乗っ取りの経路になります。
メールアドレスでなくテナントとIdPとNameIDの組で利用者を引く理由
テナントのIdPは、そのテナントの管理者が自由に設定できます。属性のメールアドレスにどのような値を入れて送るかも、その管理者が設定できる内容です。メールだけで全体の利用者表を引くと、悪意のある管理者が他社の社員のメールを送り、そのアカウントに入れてしまいます。
そこで、利用者の一意キーはテナントID、IdPのentityID、NameIDの3つの組にします。先のコードのUNIQUE制約がこれです。既存のパスワード利用者をSAMLへ移すときに限り、メールで照合しますが、照合はそのテナントが所有を検証したドメインだけに絞ります。ドメインの検証はDNSのTXTレコードで行う形が一般的です。
初回ログインで付ける既定ロールとグループ属性150件の上限の扱い
JITで作る利用者には、テナント設定の既定ロールを付けます。管理者権限を既定にすることはしません。IdPのグループ属性からロールを決める場合、Entra IDはSAMLトークンのグループ数が150を超えると、groupsの代わりにGraph APIへの参照を入れる仕様です。全グループを送らせず、IdP側でアプリに割り当てたグループだけに絞ってもらいます。
グループの値はオブジェクトIDで届くため、テナント設定に「グループIDとロールの対応表」を持たせます。ロールの更新を毎ログインで上書きするか、初回だけにするかを決める基準は、権限の管理場所です。IdP側で管理するなら毎回上書き、SaaSの画面で管理するなら初回だけにします。
JITでは消えない退職者アカウントとSCIMを併用する判断の分かれ目
JITは作ることしかできません。退職者はIdPでログインできなくなりますが、SP側のアカウントと発行済みのAPIトークンは残ります。SaaSのセッションが長い、APIトークンを発行する、利用者数で課金する、のどれかに当てはまるなら、IdPから削除を受け取るSCIMによる自動プロビジョニングを併用します。どれにも当てはまらない小規模な業務システムなら、最終ログインから90日などで無効にする定期処理で対応できる範囲です。
自社SaaSにSAML連携を載せる判断基準とSSO強制で締め出さない運用
SAML連携は、作れば終わる機能ではありません。取引先ごとの設定支援と、証明書の更新対応が続きます。
SAML連携を標準機能にする顧客層と上位プラン限定で足りる場面
情報システム部門がある従業員数百人以上の企業へ売るSaaSでは、SAML連携の有無が選定の足切りになりやすく、標準機能として持つ価値があります。逆に、利用者が各社数名で、取引先のIT部門が窓口に出てこないSaaSでは、SAML連携の設定支援が売上に見合いません。この場合は上位プラン限定にするか、Googleアカウントなどのソーシャルログインで足ります。
見送るべきなのは、SAMLを求める取引先が1社だけで、その要件が仕様書の項目として書かれているだけの場面です。実際に使う予定と、IdP側の担当者が設定に協力できるかを先に確かめます。
SSO強制の前に用意する緊急用管理者と署名証明書の更新の段取り
テナント単位で「SAML以外のログインを禁止」にすると、IdPの障害や証明書の期限切れで全員が締め出されます。強制する前に、パスワードと多要素認証で入れる緊急用の管理者を1名以上残し、その利用を監査ログに残します。
IdPの署名証明書は期限で切り替わります。新しい証明書を先に受け取ってテナント設定に並べ、IdP側で切り替わった後に古いほうを外す順序にすると、切替の瞬間にログインが止まりません。先のコードで証明書を2枚持てるようにしたのはこのためです。設定ミスでつながらないときの調べ方はSAML NameIDFormatとメタデータの不一致でSSOが失敗する原因の切り分けにまとめました。
取引先ごとのIdPとつなぐ認証基盤を、既存の業務システムや会員基盤に後付けする場合は、認証基盤・ID管理システム開発でテナント設計から相談を受けています。
よくある質問
SAML連携を自社サービスに組み込むときに、開発者からよく出る質問をまとめました。
SAML連携とSSOは何が違いますか?
SSOは1回の認証で複数のサービスに入れる状態を指す言葉で、SAML連携はそれを実現する方式の1つです。SAML 2.0でIdPとSPが署名付きのXMLをやり取りし、SPがIdPの認証結果を信用してログインさせます。同じSSOをOpenID Connectで実現することもでき、取引先の企業向けにはSAML、スマートフォンアプリやSPAにはOIDCを使う組み合わせがよく見られます。
SAML連携の設定でSP側から取引先に渡すものは何ですか?
SPのentityIDとACSのURLの2つが最低限で、多くの場合はこれらを含むSPのメタデータXMLを渡します。あわせて、NameIDの形式(Persistentを推奨)と、送ってほしい属性名(メール、氏名、必要ならグループ)を文書で伝える形です。取引先からはIdPのentityID、SSOのURL、署名証明書を受け取ります。IdP側のメタデータXMLを受け取れば、3つが1ファイルにそろいます。
マルチテナントのSaaSでSPのentityIDは1つで足りますか?
技術的には1つでも動きますが、テナントごとに分けることを勧めます。分けておくと、ACSのURLでテナントが決まり、どのIdP設定で検証するかが応答の中身に左右されません。1つの取引先が本番と検証の2テナントを持つ場合も、IdP側で別アプリとして登録できます。既に1つで運用しているなら、新規テナントから分けて移行できます。
JITプロビジョニングとSCIMはどちらを選べばよいですか?
作成だけで足りるならJIT、削除や停止もIdPから反映したいならSCIMです。JITは初回ログイン時に利用者を作るだけで、退職者のアカウントは残ります。APIトークンを発行する、利用者数で課金する、セッションが長いSaaSでは、SCIMを併用する構成です。小規模な業務システムなら、JITと最終ログイン日による無効化の組み合わせで運用できます。
SAML連携を入れた後に取引先がログインできなくなる原因は何ですか?
多いのは、IdPの署名証明書の更新でSP側の証明書が古いまま残るケースと、IdP側の属性設定の変更でNameIDの値が変わるケースです。前者は新旧2枚の証明書を並べて登録できる作りにすれば防げます。後者の影響を抑える方法は、NameIDをPersistentにし、UPNやメールを照合キーとして使わないことです。原因の調べ方はSAML-tracerで応答を採取するところから始めます。
関連記事
- SAMLとは?認証フロー・IdP/SPの仕組みとOAuth・OIDCとの使い分けを実装視点で解説:単一IdPとつなぐ基本の実装とSAMLの全体像
- SAML NameIDFormatとメタデータの不一致でSSOが失敗する原因の切り分け:SAML連携がつながらないときの調べ方
- SCIMとは?ID自動プロビジョニングの仕組みとエンドポイント実装を解説:JITで消えない退職者を同期で止める方法
- WorkOSとは:SSOとSCIMを外部化するB2B SaaS向け認証基盤を実装視点で解説:SP実装を外部サービスへ任せる選択肢
- GitHub SSOの設定とログイン|SAML連携とPAT・SSH・gh CLIの認可【2026年10月】:SAML連携を使う側のSaaSでの設定例