エンタープライズサインインとソーシャルサインインの兼ね合いについて

この記事は，[Digital Identity技術勉強会 #iddance Advent Calendar 2023](https://qiita.com/advent-calendar/2023/iddance) の13日目の記事です．

# はじめに

初めまして，🌱🌿☘️🍀と申します．読み方はお好きに決めてください．
ここ1年半ほど，OpenID ConnectをはじめとしたDigital Identity関連の技術仕様やその実装に興味を持っており，このアドベントカレンダーに参加することを決めました．

:::note warn
この記事は，丹念な調査に基づいたものではなく，「こういうやり方って大丈夫なのかな……」という疑問を言語化したものです．実際の実装の参考にはなさらないようにお願いします．
:::

# 問題意識

「Googleでサインイン」（ソーシャルサインイン）とGoogle Workspaceなどに対応するSAMLによるエンタープライズサインインの両方に対応するサービスを考えます．ある会社では，Google WorkspaceによるSSOを推進しているため，このサービスを導入するにあたって，SAMLによるフェデレーションでユーザーを作成し，あらかじめ設定してある会社の組織に所属させることにしました．

このとき，新しい社員がサービスのアカウントを作るための正しい方法は，SSOでのサインインを試みることです．これにより，サービス側のユーザーレコードも自動で作成されます．具体的には，以下のようなフローです：

1. サービス側でSAML Requestを生成し，Google WorkspaceのSSO URLにUAをリダイレクトする
1. Google Workspace側でユーザー認証を行う
1. Google Workspace側でSAML Responseを生成し，サービス側のACS URLにUAをリダイレクトする
1. SAML Responseを検証し，`NameID`を得る
1. サービス側でユーザーレコードがあるか確認し，無ければ新たに作成する

いま，新しい社員が誤って，サービス側でソーシャルサインインによってユーザーを作成してしまったとします．この場合，SAMLによるフェデレーションが行われず，ソーシャルサインインのフローによって組織に所属しないユーザーが作成されてしまいます．具体的には，以下のようなフローです：

1. サービス側でAuthorization URLにUAをリダイレクトする
1. Google Workspace側でユーザー認証を行う
1. Google Workspace側で認可コードを発行し，サービス側のCallback URLにUAをリダイレクトする
1. サービス側がGoogle Workspace側にToken Requestを投げ，レスポンスとしてIDトークンを得る
1. ID Tokenを検証し，`sub`を得る
1. サービス側でユーザーレコードがあるか確認し，無ければ新たに作成する

このような状況は，会社の情報システム管理者にとって厄介です．1人2人であれば，このようなユーザーを組織に所属させるような操作を管理コンソールから行うことができるでしょう．では，このようなユーザーが何百人もいたら？　Google Workspace側でこのサービスに条件付きアクセスを設定していたら？　頭を痛める事態になることは想像に難くありません．

また，会社としてサービスを導入する前から会社のGoogleアカウントでソーシャルサインインしてサービスを利用していたユーザーがいるかもしれません．このユーザーにもSSOでサインインしてもらうことになるでしょう．このようなユーザーに紐づいているデータは引き継げた方が良いですが，どのように引き継ぐのでしょうか．

このような状況に対応するために，サービス側のOIDC/SAML関連実装にどのような機能を追加すればいいのか，考えました．

# SSOサインインによるサインアップを強制するために考えられる対策

エンタープライズサインインを想定しているアカウントがソーシャルサインインによってサインアップしてしまうという上記の問題には，いくつかのナイーブな対策が考えらえれます．

## 指定するドメインのユーザーのソーシャルサインインによるサインアップを禁止する

問題となるユーザーレコードがソーシャルサインインによって作られなければ問題ありません．よって，ソーシャルサインインのフロー中（例えばOIDCのレスポンスが返ってきた直後，ユーザーレコードを作る前）にProviderから返されたペイロード（例えばID Token）の中身を吟味し，事前に指定された組織と関連づけられていた（「Googleでサインイン」では `hd` claim を見る）場合はソーシャルサインインのフローを失敗させることが考えられます．具体的には以下のようなフローになります：

1. サービス側でAuthorization URLにUAをリダイレクトする
1. Google Workspace側でユーザー認証を行う
1. Google Workspace側で認可コードを発行し，サービス側のCallback URLにUAをリダイレクトする
1. サービス側がGoogle Workspace側にToken Requestを投げ，レスポンスとしてIDトークンを得る
1. ID Tokenを検証し，`hd`を見て，それに関連づけられている組織があるか確認する
1. 関連づけられている組織があれば，ユーザーにエラーを表示する

ここで，その組織のユーザーであることが確認できるからといって，ユーザーを組織に所属させるようなことを行ってはなりません．そのようにしてしまうと，情報システム管理者がサービスへの条件付きアクセスを設定する余地がなくなります．

なお，サービスがメールアドレスでのサインインに対応していた場合を考えると，[「Googleでサインイン」の`hd` claim](https://developers.google.com/identity/openid-connect/openid-connect?hl=ja#id_token-hd) や[「Microsoftでサインイン」の `tid` claim](https://learn.microsoft.com/ja-jp/entra/identity-platform/id-token-claims-reference#payload-claims) による確認のような，IdP独自の claim に依存する方法には限界があります．そのため，単に`email`や`preferred_username`のドメイン／レルムによる判定を行う方法が考えられます．

Zoomには，「メールアドレスが以下のドメインのどれかに属している場合は SSO でサインイン​することをユーザーに求める」という機能があります[^1]．ここで「以下のドメイン」として許されるのは組織の「関連ドメイン」で，DNSレコードを利用してその所有を証明する必要があります．この設定により，「関連ドメイン」のメールアドレスによるサインインや，そのメールアドレスのGoogleアカウントによるソーシャルサインインが禁止され，これを試みたユーザーには適切なエラーが表示されます．

[^1]: https://support.zoom.com/hc/ja/article?id=zm_kb&sysparm_article=KB0066270#h_01EQY4X5J0KYWMQE1M4GWX5QEJ

## 指定するドメインのユーザーのソーシャルサインインがあったら，改めてエンタープライズサインインを開始する

問題となるユーザーがエンタープライズサインインのフローに乗れば問題ありません．[指定するドメインのユーザーのソーシャルサインインによるサインアップを禁止する](#指定するドメインのユーザーのソーシャルサインインによるサインアップを禁止する)と同じ議論によって，あらかじめ指定してあるドメインのどれかに属している場合は，そのユーザーにエンタープライズサインインを促すことができます．組織の関連ドメインであったわけですから，どの組織のIdPに誘導すればいいかはわかるはずです．具体的には，先のフローでエラーを表示する際にエンタープライズサインインを開始するためのリンクを貼れば十分でしょう．

より急進的には，コールバックの中でそのような判断を行って，ユーザーの応答を待つことなく直接エンタープライズサインインのフローを始めることもできます．このとき，対応しているIdPであれば，サインインすべきユーザーのヒントを与えることによって，IdP側でのユーザーの操作を不要とし，見た目にはソーシャルサインインでサインインできたように見せることも可能でしょう．具体的には以下のようなフローになります：

1. サービス側でAuthorization URLにUAをリダイレクトする
1. Google Workspace側でユーザー認証を行う
1. Google Workspace側で認可コードを発行し，サービス側のCallback URLにUAをリダイレクトする
1. サービス側がGoogle Workspace側にToken Requestを投げ，レスポンスとしてIDトークンを得る
1. ID Tokenを検証し，`email`を見て，それに関連づけられている組織があるか確認する
1. 関連づけられている組織があれば，SAML Requestを生成し，その組織のSAML IdPのSSO URLにUAをリダイレクトする
1. Google Workspace側でユーザー認証を行う
1. Google Workspace側でSAML Responseを生成し，サービス側のACS URLにUAをリダイレクトする
1. SAML Responseを検証し，`NameID`を得る
1. サービス側でユーザーレコードがあるか確認し，無ければ新たに作成する

# ユーザーレコードをSSOサインイン導入後も使い続けるために考えられる対策

ソーシャルサインインに基づくIdentityとエンタープライズサインインに基づくIdentityを紐づけるには，単に同じemailだから大丈夫，というわけにはいきません．OIDCにおける`sub` claim のような，（サービスにとって）一意で不変なIDの一致をもってIdentityを紐づけることをまずは考えます．しかしながら，私の調べた限り，Google WorkspaceはSAML IdPとして，OIDCにおける `sub` claim の値を返しません（！）．そのため，別のやり方を考える必要があります．

## ephemeralな認証情報を発行してソーシャルサインインとエンタープライズサインインの間の同一性を保証する

結局，苦し紛れに思いついた方法は「ソーシャルサインインによる短めのセッションが残っている間にエンタープライズサインインも行ってもらう」ことでした．具体的には以下のようなフローになります：

1. サービス側でAuthorization URLにUAをリダイレクトする
1. Google Workspace側でユーザー認証を行う
1. Google Workspace側で認可コードを発行し，サービス側のCallback URLにUAをリダイレクトする
1. サービス側がGoogle Workspace側にToken Requestを投げ，レスポンスとしてIDトークンを得る
1. ID Tokenを検証し，`sub`と`email`を得る
1. `sub`を元にユーザーレコードを得る
1. `email`のドメインに関連づけられた組織と，その組織のSAML IdPのSSO URLを得る
1. ephemeralなトークンを発行し，UAのCookieに書き込む
1. SAML Requestを生成し，その組織のSAML IdPのSSO URLにUAをリダイレクトする
1. Google Workspace側でユーザー認証を行う
1. Google Workspace側でSAML Responseを生成し，サービス側のACS URLにUAをリダイレクトする
1. SAML Responseを検証し，`NameID`を得る
1. ephemeralなトークンがあるか確認したのち検証し，それを元にユーザーレコードを得る
1. ユーザーレコードに`NameID`を関連づける

なお，Google Workspaceにおいては，SAMLの`NameID`は（デフォルトでは）OIDCの`email`と一致し，Entra IDにおいては，SAMLの`NameID`は（デフォルトでは）OIDCの`preferred_username`と一致していると理解しています[^2]．

[^2]: NameID Formatによって変わりうるのかもしれませんが，よく調べられていません．

# おわりに

同じIdPのユーザーが複数の認証経路を通って認証される場合の困難について書いてきました．この問題は私がサービスを実装するにあたって実際に直面したもので，かなり頭を悩ませましたし，現在もこれで万事問題ないとは到底確信できません．
みなさまからの[マサカリ](https://qiita.com/kaityo256/items/d7d8f7a0e522895c8f5d)は，歓迎しております．