ykitaa.dev
約 9 分

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 に見立てた連携を一周させ、アサーションの実物を眺めてみるつもりです。

参考