SAML による SSO — パスワードを入れる場所を 1 か所に集める
セキュリティ要件として SSO が求められる場面があります。そこで出てくるのが SAML なのですが、OIDC は触ったことがあっても SAML は未知のまま、という状態だったので整理しました。
引っかかっていたのは規格そのものではなく、何のために何を集約しているのかが見えていないことでした。この記事では、**「パスワードを入力する場所を 1 か所に集める」**という一点を軸に、SAML が何を運んでいるのか、そして後から SSO を足すときに何が起きるのかを書きます。
細かい仕様(バインディングの種類、XML 署名の検証手順など)には踏み込みません。全体像を掴むのが目的です。
SSO とは「入力する場所を集めること」
SSO と聞くと「一度ログインすれば他のアプリにも入れる」という利便性の話に聞こえますが、本質は配置の問題です。
flowchart LR
subgraph A["集約前"]
U1[社員] -->|ID/パスワード| X1[勤怠アプリ]
U1 -->|ID/パスワード| X2[経費アプリ]
U1 -->|ID/パスワード| X3[自社サービス]
end
subgraph B["集約後(SSO)"]
U2[社員] -->|ID/パスワード| IdP[IdP]
IdP -.署名付きの「認証済み」.-> Y1[勤怠アプリ]
IdP -.-> Y2[経費アプリ]
IdP -.-> Y3[自社サービス]
end
左はアプリの数だけパスワードが存在します。アプリごとにパスワードが保存され、退職者のアカウントが消し忘れられ、どこか 1 つが漏れれば他にも試されます。
右では、パスワードを受け取るのは IdP(Identity Provider)だけです。アプリ側はパスワードを知らず、保存もせず、検証もしません。受け取るのは「この人は認証済みだ」というIdP からの申し送りだけです。
ここで出てくる「申し送りをどう渡すか」の取り決めのひとつが SAML です。
SAML が運んでいるもの
SAML(Security Assertion Markup Language)が運ぶのは、アサーションと呼ばれる書面です。assertion は「言い切り」の意味で、中身は IdP からの申し送りそのものです。
この人の認証、うちで済ませました。パスワードも MFA も確認済みです。
OIDC の ID トークンとほぼ同じ役割だと考えると早いです。形式が XML か JWT かの違いが大きく、やっていることは近いものです(アサーションは所属グループなどの属性も一緒に運ぶので、OIDC でいえば ID トークンと UserInfo を兼ねています)。
| 役割 | SAML | OIDC |
|---|---|---|
| 「認証済み」を伝えるもの | アサーション | ID トークン |
| 形式 | XML | JWT |
| 改ざんの検知 | XML 署名 | JWS |
| 発行者 | Issuer |
iss |
| 宛先 | Audience |
aud |
| 有効期限 | NotOnOrAfter |
exp |
中身を削ぎ落とすと、こういうことが書いてあります。
<saml:Assertion>
<saml:Issuer>https://idp.example.com</saml:Issuer> <!-- 誰が認証したか -->
<saml:Subject> <!-- 誰を認証したか -->
<saml:NameID>taro@example.com</saml:NameID>
</saml:Subject>
<saml:Conditions NotBefore="..." NotOnOrAfter="..."> <!-- いつまで有効か -->
<saml:AudienceRestriction> <!-- 誰に宛てたものか -->
<saml:Audience>urn:auth0:my-tenant:acme-saml</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<ds:Signature>...</ds:Signature> <!-- IdP の署名 -->
</saml:Assertion>ここで大事なのは、この書面がユーザーのブラウザを経由して届くことです。IdP がこちらのサーバーへ直接渡すのではなく、「これを自社サービスに POST してね」とブラウザに持たせます(広く使われている HTTP POST バインディングの場合)。
flowchart LR
IdP[顧客の IdP] -->|アサーション| B[利用者のブラウザ]
B -->|アサーション| SP[自社サービス側]
B -.- N([配達人は信用できない])
書き換えられていないかを確かめる必要があるのは、このためです。「誰宛てか」「いつまで有効か」が書面自体に書いてあるのも同じ理由です。
肝は最後の署名です。署名は「相手からもらって貼り付けるもの」ではありません。それぞれが自分の秘密鍵で署名し、相手は事前にもらった証明書(公開鍵)で検証します。
sequenceDiagram
participant IdP as 顧客の IdP
participant SP as 自社サービス側
note over IdP,SP: 設定するとき(最初に 1 回だけ)
IdP->>SP: 署名用の証明書(公開鍵)を渡す
SP->>IdP: 送り先 URL と Entity ID を渡す
note over IdP,SP: ログインのたび
IdP->>SP: 秘密鍵で署名したアサーション
SP->>SP: もらっておいた証明書で署名を検証
秘密鍵はどちらも手元から出しません。渡すのは検証にしか使えない公開鍵です。SSO の設定作業で「顧客から証明書をもらって登録する」のは、この検証用の鍵を受け取る作業にあたります。
考え方は HTTPS の証明書と同じです。相手を信じる根拠を自己申告ではなく署名に置いている。違うのは、保証している対象が「サーバーの身元」ではなく「ユーザーが認証を通ったという事実」であることだけです。
逆に言えば、署名を検証しないアプリは、誰でもログインできるアプリです。宛先(Audience)や有効期限の確認も同じで、ここを飛ばすと他社向けのアサーションを使い回されます。自前で実装せず、次に書く「間に挟む」構成に任せるのが現実的です。
Auth0 を間に挟むとどうなるか
自社サービスを顧客企業の IdP と繋ぐとき、自分のアプリに SAML を実装する必要はありません。Auth0 のような IdP サービスを間に置くと、こうなります。
sequenceDiagram
autonumber
participant B as 社員のブラウザ
participant App as 自社サービス
participant A0 as Auth0
participant IdP as 顧客企業の IdP
B->>App: ログインしたい
App-->>B: Auth0 へリダイレクト(OIDC)
B->>A0: 認証して
A0-->>B: 顧客の IdP へリダイレクト(SAML AuthnRequest)
B->>IdP: 認証して
Note over B,IdP: ここで ID / パスワード、MFA を入力
IdP-->>B: アサーションを Auth0 へ POST させる
B->>A0: 署名付きアサーション
A0-->>B: 自社サービスへリダイレクト
B->>App: 認可コード / ID トークン(OIDC)
やり取りはすべてブラウザのリダイレクトで進みます。自社サービスと Auth0、Auth0 と顧客の IdP が裏で直接通信しているわけではありません。
注目してほしいのは Auth0 の両隣で喋っている言葉が違うことです。
- 自社サービスから見た相手は Auth0 で、会話は OIDC
- Auth0 から見た相手は顧客の IdP で、会話は SAML
つまり Auth0 が翻訳機として挟まっています。顧客が Entra ID でも Okta でも、あるいは別の SAML 対応 IdP でも、自社サービス側のコードは一切変わりません。顧客が増えるたびに SAML の実装差異と戦う、という事態を避けられるのが、この構成の実利です。
Auth0 と顧客の IdP の間では、署名が 2 方向に出てきます。混同しやすいので整理しておきます。
flowchart LR
A0[Auth0] -->|"① ログイン要求<br/>Auth0 の鍵で署名(任意)"| IdP[顧客の IdP]
IdP -->|"② アサーション<br/>IdP の鍵で署名(必須)"| A0
本命は②です。これを検証できないと、誰でも「この人は認証済み」と名乗れてしまいます。①は「要求が登録済みの相手から来たか」を確かめるもので、省略されることもあります。顧客からもらった証明書は②の検証に、こちらが渡した証明書は①の検証に使われます。
集約の代償
ここで視点を裏返します。パスワードを入力する場所を 1 か所に集めたということは、そこが破られたら全部が破られるということです。集約前なら 1 つのアプリに留まった被害が、集約後は連携しているすべてのアプリに及びます。
だから SSO は、守りも 1 か所に集中させることとセットになります。顧客の IdP 側で MFA を必須にしておけば、連携するアプリは何も実装せずに保護されます。パスワードの集約と同じ構図が、そのまま防御にも効いてくるわけです。
ただし「IdP 側で守られているはず」という前提は、後から SSO を足すときに崩れます。それは最後のケースで扱います。
Auth0 では何を設定するのか
実際の作業はそれほど多くありません。
顧客の IdP と繋ぐには、Auth0 で SAML の Enterprise Connection を作ります。交換する情報は 2 方向です。
| 向き | 渡すもの |
|---|---|
| 顧客の IdP → Auth0 | サインイン URL と X.509 署名証明書(まとめてメタデータで受け取るのが楽) |
| Auth0 → 顧客の IdP | Post-back URL(https://{ドメイン}/login/callback?connection={接続名})と Entity ID(既定で urn:auth0:{テナント}:{接続名}) |
Auth0 側の 2 つは、先ほどのアサーションでいう「どこに送るか」と「誰に宛てたものか」にあたります。アサーションの Audience に入るのは、この SP 側(= Auth0)の Entity ID です。IdP 側にも Entity ID があるので、どちらの話かを取り違えないよう注意してください。
ログイン画面で顧客ごとに接続を出し分けるには Home Realm Discovery を使います。入力されたメールアドレスのドメインを見て、対応する IdP へ飛ばす仕組みです。
MFA は Dashboard の Security > Multi-factor Auth で、要素(WebAuthn、OTP、プッシュ、SMS など)を有効にし、適用方針を選びます。Never / Use Adaptive MFA(リスクに応じて要求)/ Always の 3 択で、もっと細かい条件を付けたい場合は post-login の Actions で書けます。
ひとつ注意があります。顧客の IdP で認証する場合、MFA は顧客側で済んでいることが多いです。そこに Auth0 の MFA を重ねると二重に要求することになります。どちら側で担保するかは技術というより契約や運用の話なので、SSO 対応を始める時点で確認しておくのがよさそうです。
移行でつまずく 2 つのケース
ここまでは新規に繋ぐ話でした。実際にはすでに使われているサービスに後から SSO を足すことのほうが多く、そこで起きる混乱はだいたい次の 1 点に行き着きます。
Auth0 では、接続(connection)が違えば別のユーザーになります。 user_id が auth0|...(データベース接続)と samlp|...(SAML 接続)のように、提供元ごとに分かれるためです。メールアドレスが同じでも、自動的に同一人物として扱われるわけではありません。
ケース 1: SSO を有効にしたら、一部の社員がログインできなくなった
顧客企業で SAML 接続を有効にしたところ、それまでメールアドレスとパスワードで入れていた人が入れなくなる。どこで止まるかは 3 通りあります。
flowchart TD
U[社員がログインしようとする] --> HRD{メールのドメインが<br/>SAML 接続に一致する?}
HRD -->|いいえ| DB[これまでどおり<br/>パスワードで入れる]
HRD -->|はい| Q1{IdP 側でこのアプリの<br/>利用が許可されている?}
Q1 -->|いいえ| NG1["① IdP で弾かれる<br/>パスワード画面にも戻れない"]
Q1 -->|はい| Q2{アプリは何でユーザーを<br/>識別している?}
Q2 -->|user_id| NG2["② 入れるが、別人として扱われ<br/>過去のデータが見えない"]
Q2 -->|検証済みメール| OK[③ そのまま使える]
最初の分岐が Home Realm Discovery です。これを設定すると、入力されたメールのドメインを見て IdP へ飛ばすので、対象ドメインの人は従来のパスワード入力画面にたどり着けなくなります。
①でつまずくのは、たいてい「アカウントが無い」からではありません。社員のアカウントは IdP に既にあります。足りないのはそのアプリを使ってよいという割り当てです。Entra ID ならエンタープライズアプリケーションへのユーザー/グループの割り当て、Okta ならアプリへのアサインにあたります。誰を対象にするかを顧客側で決めてもらうのが、有効化前にやるべきことです。
②を避けるには、アプリ側でメールアドレスを軸に名寄せするか、Auth0 のアカウントリンクで既存ユーザーに SAML の identity を紐づけます。
ただし、メールアドレスでの名寄せには注意が要ります。同じメールアドレスで別の経路からサインアップできる状態だと、他人の既存アカウントに合流できてしまうからです。名寄せを許すのは「信頼する SAML 接続から来て、かつメールのドメインがその接続のものと一致する場合だけ」のように、条件を絞るべきです。
ケース 2: MFA を登録済みのアカウントに、後から SAML を有効にした
こちらも「別ユーザーになる」が効きます。
flowchart LR
subgraph B["SAML 有効化の前"]
U1["auth0|abc123"] --- M1[MFA 登録済み]
end
subgraph A["SAML 有効化の後"]
U2["samlp|acme|taro"] --- M2[MFA は未登録の状態から]
end
B -. メールは同じでも別ユーザー .-> A
IdP[顧客の IdP で MFA 済み] -->|ポリシーが Always だと<br/>ここでもう一度 MFA| U2
それまで登録した MFA は引き継がれません。 認証アプリもセキュリティキーも、登録はユーザー単位で保存されているためです(ケース 1 で触れたアカウントリンクで同一ユーザーに紐づけた場合は、この限りではありません)。
そして厄介なのが、MFA ポリシーを Always にしていると、SAML 経由のログインにも Auth0 の MFA が要求されることです。顧客の IdP で済ませているのに、もう一度求められる。利用者から見れば二度手間です。
ここは post-login の Actions で、接続を見て切り分けます。
exports.onExecutePostLogin = async (event, api) => {
// 顧客の IdP で認証した場合、MFA はそちらに任せる
if (event.connection.strategy === "samlp") {
api.multifactor.enable("none");
return;
}
api.multifactor.enable("any");
};やり方は 2 通りあります。ダッシュボードのポリシーを Always のままにして上のように打ち消す方法と、ポリシーを Never にして要否の判断をすべて Actions に寄せる方法です。Auth0 のサポート記事は後者を案内しています。設定画面とコードのどちらを読めば挙動が分かるのか、という違いなので、チームで統一しておくのがよさそうです。
もうひとつ、Use Adaptive MFA(リスクに応じて要求する設定)を使っている場合は、SAML の IdP-initiated フローでは効かないという制約があります。「IdP のポータルからアプリを開いたときだけ MFA が出ない」という挙動になるので、そこを前提に設計する必要があります。
なお、旧アカウント側のデータベース接続を生かしたままにすると、パスワードでも入れる状態が残ります。SSO を必須にしたいなら、移行が終わった時点でその接続を閉じるところまでが作業です。
おわりに
整理すると、こうなります。SSO はパスワードを入力する場所を 1 か所に集める仕組みで、SAML はその「認証済み」を署名付きで運ぶ取り決めです。アプリ側はパスワードを受け取らず、IdP の署名を検証するだけになります。
新規に作るなら OIDC のほうが扱いやすいのは確かです。それでも SAML が現役なのは、企業の IdP 側が長く対応してきた規格だからで、こちらの好みでは選べません。幸い、間に Auth0 のようなサービスを挟めば、翻訳は任せて自分は OIDC だけを相手にできます。
次は、実際にテナントを作って顧客側 IdP に見立てた連携を一周させ、アサーションの実物を眺めてみるつもりです。
参考
- Connect Your App to SAML Identity Providers — Auth0 を SP として SAML IdP に繋ぐ設定
- Enable Multi-Factor Authentication — 適用方針(Never / Adaptive / Always)
- Identify Users —
user_idの形式。接続ごとに別ユーザーになる根拠 - Adaptive MFA — リスク評価の内容と、IdP-initiated フローでの制約