本記事は Digital Identity技術勉強会 #iddance Advent Calendar 2020 の11日目の記事です。
自分たちで管理しているサービス(以下、1st party client)及びそのアカウントに対して、OAuth及びOpenID Connectのプロセスを導入することについて話します。
正直、自分の中で完全に答えがまとまっている状態ではありませんが、つよつよID厨の皆様方の意見を聞きたいので、現状の考えをまとめておきたいと思います!
背景
※ この背景はフィクションです。実在の人物や団体などとは一切関係ありません。
昔々、あるところに、Aというサービスがありました。そのサービスは、1つのバックエンドと、iOS/Androidなどの複数のフロントエンドから構成されておりました。アカウントの登録、ログイン、ログアウト、退会、パスワード変更等の機能はすべて、他の機能と共にバックエンド内に実装されておりました。ログインのUIはフロントエンドで管理され、ユーザがログインを行うと、バックエンドでログインセッションが作成され、フロントエンドに対しては独自のBearer Tokenが発行され、それを用いてバックエンドのAPIにアクセスできる状態になっておりました。規模が小さいうちは、皆なかよく幸せに開発しておりました。
しかし、規模が大きくなってくるといろいろな問題が見受けられる様になりました。まず、アカウントの各種機能と、他の機能との依存関係が増えていきました。成長に伴い、いろいろな機能が提案され、その機能を実現するためにアカウントの各種機能に少し手を入れるということが繰り返されたためです。また、サービスとしての知名度向上に伴い、アカウント周りの機能に対する攻撃も増えてきました。そのため、バックエンドのEndpointではデバイスの情報を取得し、特定デバイスからのアクセス制限やrate limitをかけられるようにされました。さらに、3rd partyに対しても、バックエンドの機能を提供したいという要望も出て来ました。独自Bearer Tokenを3rd partyに対して提供するのはまずかろうということで、1st partyで利用しているTokenとは別のTokenが開発されました。
更に規模が大きくなり、一つのバックエンドで管理できる状態ではなくなり、Microservices化しようという話になりました。アカウントの各種機能は、他のサービスと依存しているためなかなか切り出すことができません。その間にも、別の認証方法を追加しようとか、他の3rd partyのサービスとID連携をしようとか、別の1st partyのclientを作りたいと言った話が持ち上がり.....。
以上の話は、あくまでフィクションですが、このサービスは、今後どのようなかたちにしていくべきでしょうか?
一つの選択肢として、1st partyで利用しているTokenの発行や利用のプロセス・認証のプロセスを、OAuthやOpenID Connectといった標準仕様にそって作り直すことが考えられます。そうすることで、権限の管理を1st party/3rd partyの区別なく同じ仕組みで管理できたり、アカウント・認証・認可を標準的な形で、他の機能と切り離す事ができると期待できます。
もともと、Auth0やAuthleteといったIDaaSを利用していたら、このような問題に頭を悩ませることはなかったかもしれません。もしかしたら、今からでもIDaaSに乗り換えるという選択肢もあるかもしれません。でも、物語の登場人物は、駆け出しID厨だったので、こんな機会は滅多にお目にかかれない!ということで、自分で頑張る方向を選択したようです。僕ではないです。フィクションです。
OAuth/OIDCの基本的な流れ
1st partyのことを考える前に、3rd partyにおいて、OAuth/OIDCがどのように利用されるのかを再確認しておこうと思います。以下の構成はあくまで一例ですが、1st partyにおける利用例を明確にするために以下の形を想定します。
- サービスは、バックエンド(RP-Server)とフロントエンド(RP-Client)で構成される
- 外部サービスが提供する認証機能をつかって、外部サービスのResourceを利用する(例えば、Google認証でログインできるスマホアプリ)
- Authorization Code Flowを利用する
OAuth 2.0 for Browser-Based Appsにあるように、バックエンド(RP-Server)があるのであれば、そちらをOAuthのConfidential Clientとして取り扱う方が推奨されると思います。ただ今回の場合、Aサービスで使っているBearer TokenをOAuthのプロセスを経て取得したAccessTokenに置き換える話につなげたい都合上、フロントエンド(RP-Client)でAccessTokenを取り扱っているPublic Clientの形をベースにしました。
また、IdP側の認証機能の提供は、認可サーバ(Authorization server)とは別の認証サーバ(Authentication server)が行うと書きましたが、IdPが提供している認可サーバが認証機能を提供していると考えても不都合ないです。
またまた、以下の流れの説明では、認証済みかどうかを確認する情報として、IdP(Authorization server)で使われているものをOP-Session、RPで使われているものをRP-Sessionとしています。正式な名称があるのかどうか分からなかったので便宜上そのように書いています。
① ログイン開始
まず、ユーザがログインを実行すると、RP-Clientはブラウザ経由でAuthorization serverに対して、Authentication Requestを投げます。もし、RP-ClientがSPAのようなweb applicationであれば、authorize endpointに遷移するだけですが、native applicationである場合は、外部ブラウザなどを開いて、アクセスすることになります。
② 認証
Authorization server側では、まずユーザが認証済みであるかどうかを確認します。基本的にhttp only cookieに認証情報(以下、OP-Session)が保存されているかどうかを確認することになると思います。もし認証していなければ、Authentication serverのログイン画面に遷移し、認証を経て、Authorize endpointに再度リダイレクトされることになります。このとき、検証可能なOP-SessionがAuthorization serverに共有されます。
③ 認可及びトークン発行
Authorization serverでは、OP-Sessionを検証し、ユーザが認証していることを確認できたら、認可画面を出し、アクセストークンを発行してよいかどうかユーザに同意を求めます。ユーザが同意した場合、Authorization serverは、Authorization Codeを発行し、RP-Cilentのredirect_uriに遷移します。RP-Clientは、取得したAuthorization CodeをAuthorization serverのtoken endpointに送り、Access TokenやRefresh Token, ID Tokenといった各種トークンを取得します。
④ ログインセッションの発行
RP-Clientは、受け取ったID TokenをRP-Serverに引き渡します。RP-Serverは受け取ったID Tokenを検証し、IdPでの認証できていると判断できれば、ID Tokenのsubjectに紐づくRPにおけるアカウントをログイン状態にします。RP-Clientに対してはログインしたことを示す何かしらの情報が送られるでしょう(以下、RP-Session)。web applicationであれば、ログイン済みsession ID がhttponly cookieに入るでしょう。native applicationの場合であれば、Bearer Tokenのようなものが渡されることになるかもしれません。
⑤ Serverへのアクセス
RP-Clientは、Resource Serverにアクセスするとき、Authorization HeaderにAccess Tokenを付与します。一方で、自分たちのバックエンドであるRP-Serverにアクセスする時は、RP-Session(webならcookieに保存した情報、nativeならBearer Token等)を使ってアクセスをすることになると思います。
ここでは、基本的なパターンとしてログイン時の挙動をかきました。
この他にも、アカウント登録時の挙動、ログイン前のアクセス、認証情報の追加変更、管理者側からのユーザの強制ログアウト・強制退会等の機能が、3rd partyにおいてどのように実現するかを考えておくと、1stpartyでの想像する材料になると思います。
1st party clientにおけるOAuth/OIDCの導入
では、1st partyに対して、上記のようなOAuth/OIDCのプロセスを当てはめてみるとどのような形になるでしょうか?また、どのような問題が起きるでしょうか?
Aサービスにおいて、どんな変更をする必要があるかの視点から考えてみます。
1. ブラウザを介した共有の認証UI作る必要ある
「① ログイン開始」においてまず気になるのは、**「ブラウザを介した共通の認証UI」**を作る必要があるところだと思います。
Aサービスにおけるログインでは、フロントエンドでログインのUIを表示し、ユーザから受け取ったクレデンシャルをバックエンドに引き渡し、認証できたら、ログインセッションを作成して、Bearer Tokenをフロントエンドに返すという形でログインが進みます。
しかし、OAuth/OIDCのプロセスでは、まずAuthorization serverにAuthentication Requestを投げて、IdP側の認証セッション(OP-Session)がなければ、Authentication serverにリダイレクトし、認証セッションを取得し認可に進むという形をとるので、基本的にはすべてブラウザ上で行われます。
[1-1] そのため、すべてのClientにおいて、認証関連のUIは共通化する必要が出てきます。つまり、どのデバイスでも(PCでもmobileでも)、UXを損なわない形でログイン画面を作る必要が出てきます。
[1-2] もしくは、Authorization serverから認証のためにリダイレクトする先をnativeにして、cookieに頼らずに認証をしたことをAuthorization server側に伝える仕組みがあれば、認証に関するUIをwebではなくnative側で提供する事もできるかもしれません。しかし、認証に関するUIをClientの種類で共通にする方が、認証方法の追加の影響範囲が小さくなったり、セキュリティ的に守らなきゃいけない範囲が小さくなるので、メリットがあるように思えます。
2. デバイス情報の取得をどうしようか?
「② 認証」のプロセスで気になるところは、**「デバイス情報の取得」**です。
Aサービスにおいては、フロントエンドがバックエンドのログインEndpointと直接やり取りしています。ログインEndpointというのは往々にして攻撃の対象となる場所なので、いろいろな対策が取られていると思います。その中には、デバイスの情報を取得して、ログインを制限するとか、どのデバイスからログインされたかを記録、ユーザに表示とかしていたりもするでしょう。
しかし、OAuth/OIDCのプロセスを導入すると、共通の認証UIを通して認証を実行することになります。つまり、今まではフロントエンドがバックエンドと直接やり取りしていたところが、ブラウザを介してやりとりすることになります(RP-ClientとAuthentication Serverとやり取りするのではなく、Userがブラウザを介してAuthentication Serverとやり取りする)。そうすると、デバイスの情報をAPIで送りつけるという形はとれなくなります。
[2-1] 対応としては、デバイス情報の取得は、認証同じタイミングではなく、Clientがバックエンドと直接やり取りを行う部分、例えば、token endpointでAccessTokenを取得するタイミング(③)や、ログインセッションを発行するタイミング(④)で行うことになると思われます。
[2-2] もしくは、デバイス情報をAPIに送る形ではなく、リクエストのHeaderに常につける形も考えれます。
3. 認可画面出す必要あるのか?
「③ 認可及びトークン発行」の段階で気になるのは、**「認可画面の必要性」**です。
[3-1] OAuthにおいて認可画面で、Resource ownerであるユーザの同意を得ているものは、『3rd party clientに対して、Resourceにアクセスする権限を付与するかどうか』です。1st partyにおいては、そのclientにおいてアカウントを作成している時点で、持つことを許可していると言えるので、改めて同意を取る必要は無いように思えます。
OAuth 2.0 for Browser-Based AppsやOAuth 2.0 for Native AppsのClient Impersonationの項目に、『特にpublic clientにおいて、認可画面スキップしてはいけない』と書かれていますが、認可画面あったところで、攻撃者が組み立てるAuthentication Requestのclient_idが被害者のものなら、認可画面に出るのは被害者の名前だし、認可画面を出すことによってユーザが攻撃に対処できるとはちょっと思えませんでした。。認可画面出す事自体に拘るよりも、redirect_uriを「完全一致で確認する」、「Universal Links/App Links使う」とかを頑張った方がいいように思いました。
[3-2] また、スキップしない方の案として、「アカウント作成の規約に対する同意」をもって、アクセストークンを発行させるというやり方も検討の余地があるかもしれません。つまり、3rd partyにだす同意画面と、1st partyに出す同意画面は、異なる内容の同意画面にする形です。そうすると、ユーザが規約に同意した履歴も残せるし、規約が更新されたときに再度同意しなければ、トークンの再発行を許さないという状況も作れるかもしれません。
4. scopeそこまで絞れない気がするけど大丈夫?
「③ 認可及びトークン発行」の段階でもう一つ気になるのは、**「発行するAccessTokenの持つ権限を小さく絞る事が難しい」**というところです。
基本的に、OAuthではトークンが漏洩したときの影響を最小限にするため、利用する機能やResourceのみを指定し、ユーザの同意を取った上で、権限を付与するとしています。しかし、1st partyの場合、Serverが提供する機能をほぼ全て使うことになるので、全権限を持った最強のトークンを発行するしかなくなります。バックエンド側の構成にもよりますが、Resource Indicators for OAuth 2.0のような仕組みがあっても、同様のことが言えると思います。
[4-1] OAuth 2.0 Incremental Authorizationを参考に、ユーザの状態によって、段階的に権限を付与していく形なら多少緩和することができるかもしれませんが、その分、Clientの色んな所でAuthentication Request組み立てて投げるのは、Client側としてもあまりしたくないだろうし、そのたびにリダイレクトが発生して、UX的にどうなのかという話にもなりそうです。
[4-2] できる限りscopeを活用する方向で考えてみます。ほとんどの機能を使うと言っても、ユーザ向けアプリケーション側から、管理者用の機能を使うとかは流石にしないと思います。そのため、scopeを切る粒度が、機能やResource単位ではなく、role単位になっていれば、上手く制限できるかもしれません。まぁ、それはscopeではなく、別のclaim(たとえば、subjectの種類みたいな....)でやるべきなのではないかと言われたらそうかも知れません。
[4-3] scopeを絞るのは無理という方向で考えてみます。scopeが絞れないことによる懸念は、トークンが漏洩したときの影響範囲の大きさです。であれば、トークンが漏洩したときの影響を小さくする方法があれば、AccessTokenの権限を絞れないことによるセキュリティリスクを軽減できるかもしれません。考えられるのは、OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer (DPoP)のような、トークンの所有証明の仕組みだと思います。これを使って当該アクセストークンを利用できるClientが限定されていれば、仮にトークンが漏洩しても攻撃者がそれを利用できません。加えて、トークンを所有していることを示す情報(DPoPでは、DPoP Proof JWT)は、基本的にResourceServerに対する全リクエストで付けることになるので、[2-2] で上げたデバイス情報の取得と整合しそうな感じがします。
5. どうやってログインセッションを発行しようか?
「④ ログインセッションの発行」のタイミングで気になる問題は、**「どのようにログインセッションを発行するのか?」そして、「どのようにログインセッションを削除するのか?」**です。
Aサービスでは、ユーザが認証を行うと同時にバックエンドでログインセッションとBearer Tokenが発行され、CilentはそのBearer Tokenを使って、ログインしているかどうかを確認することができました。
しかし、3rd partyにおけるOAuth/OIDCのプロセスをみると、IdPにおける認証とRPにおけるログインセッションの発行は分離されており、仮にIdP側の認証セッション(OP-Session)の有効期限が切れていたとしても、Acceess Tokenはもちろん、サービス側のログインセッション(RP-Session)も切れておらず、RP-Server/Resource-Serverどちらにもアクセスできる状態になっています。この状態を1st partyに対してどのように持ち込むのかを考える必要があります。
選択肢としては、大きく2パターン有ると思います。
- 3rd partyと同様に、『ID Tokenを使ってログインセッションを作成する』
- Aサービスと同様に、『認証時にログインセッションを作成する』
[5-1] 1つ目は、3rd partyと同様に、『ID Tokenを使ってログインセッションを作成する』やり方です。この場合、ユーザが認証したタイミングでは、OP-Sessionが発行されますが、clientが利用するログインセッションは発行されていません。その後、各種トークンが発行された後、ID Tokenをバックエンド(RP-Server)に渡してログインセッションを作成します。そして、RP-Serverに対してアクセスする際はRP-Session、Resource Serverに対してアクセスする際はAccessTokenを利用します。
ただこの形は、Client側に複雑さを与えることになります。アクセストークンを使ってアクセスするAPIとログインセッションを使ってアクセスするAPIの区別、2つの認証情報の管理(発行、利用、期限切れの対応、再発行等)です。3rd partyにおいては、両者は発行者が異なるものなので、別れていることに違和感はありませんが、1st partyにおいてはこの2つを明確に分けるべきかどうかは悩ましいところです。また、既存のバックエンドにあるAPIを3rd partyでも利用可能なEndpointと、1st party専用のEndpoint分けることになりますが、既にEndpointの数が多い場合、その区別自体も難しそうです。
[5-2] 2つ目は、Aサービスの形と同様に、『認証時にログインセッションを作成する』方向です。この場合、認可サーバ側でログインセッションを管理することになるので、実質OP-Sessionをサービスのログインセッションとして扱う形になります。ただ、OP-Session自体は基本的にcookieで扱われるので、native applicationの場合、client側でログインセッションの状態を確認するのが難しくなります。client側がログインセッションを確認する方法を準備する必要があります。
その方法としては、Access Tokenとログインセッションをbindする方法が考えられます。つまり、Client側ではAccessTokenのみ取り扱い、Access Tokenが使えるならバックエンドのログインセッションは生きているし、バックエンドでログインセッションが切られたらAccessTokenが使えなくなるという形です。bindする方法は、ログアウト時にAccessTokenを無効にするか、AccessToken利用時にログインセッションを確認するかのどちらかになると思います。Clientが簡単になる分、認可サーバ側が難しくなりそうです。また、通常のOAuth/OIDCの仕様で、OP-SessionとAccess Tokenがbindするみたいなものはないので、普通は発生しないようなめんどくさい問題に直面するかもしれません。。
どちらのやり方のほうが適切化は、既存のサービスの状態によると思います。既存のサービスの内部構造がある程度独立していて、RP-ServerとResourceがきれいに分離できるのであれば、『ID Tokenを使ってログインセッションを作成する』方が、きれいにまとまるでしょう。しかし、RP-ServerとResourceがきれいに分離できない場合、わざわざそこを明確に分けなきゃいけない形よりも『認証時にログインセッションを作成する』方にした方がシンプルになると思いました。
6. どうやってログインセッションを破棄しようか?
どちらの形で、ログインセッションを管理するかは、「どのようにログインセッションを削除するのか?」にも影響します。
Aサービスでは、バックエンド側でBearer Tokenのようなものを無効にすることでログアウトを実行します。そのため、ログアウトのリクエストがClient側から来ても、管理者側から強制ログアウトとして来ても、同様にログアウトすることになります。
[6-1] 『ID Tokenを使ってログインセッションを作成する』場合、RP-Sessionを無効にすることでログアウトを行います。しかし、AccessTokenやOP-Sessionが無効になるわけではないので、AccessTokenを使ってアクセスできるEndpoint(Resource Server)を利用している状態では、Client側はログアウトされたことを認識できず、RP-Sessionを使ってアクセスできるEndpoint (RP-Server)を利用するタイミングでログアウトしたことを認識します。そのため、多少時間差が生じる可能性はあります。
[6-2] 『認証時にログインセッションを作成する』場合、OP-Sessionを無効にすることでログアウトを行います。また、OP-Sessionが有効であるときしかAccess Tokenが利用できない状態になっているので、Client側はバックエンドのEndpointにアクセスしたタイミングでログアウトされていることを認識します。
これら、ログインセッションの発行・削除の形式は、Signle Sign-On・Single Sign-Out のやり方にも影響してきます。例えば、複数の1st party clientが存在するときに、「一つのcilentでログインしていれば、他の1st party clientでもログインした状態になる」、「一つのclientでログアウトすれば、他の1st party clientでもログアウトされる」という状況を作るときの話です。
Single Sign-Onに関しては、『ID Tokenを使ってログインセッションを作成する』場合、『認証時にログインセッションを作成する』場合、どちらにおいても、同じブラウザを開き、OP-Sessionが生きていればRP-SessionもしくはAccessTokenを取得し、ログイン状態を作れると思います。
Sigle Sign-Outに関して、『認証時にログインセッションを作成する』場合では、ログアウトしてOP-Sessionが無効化された場合、そのOP-Sessionを基に発行したAccessToken及びRefreshTokenがすべて無効になり、各1st party clientはAccess Tokenが有効かどうかを持ってログイン状態かどうかを判断しているので、ログアウトしたら関連する1st party clinet全部でログアウトされるという状況が作れます(逆に、ログアウトしないということはできませんが...)。一方で、『ID Tokenを使ってログインセッションを作成する』場合、RP-Sessionは、各Clientにおいて独立して管理されているものなので、あるClientのRP-Sessionを無効にしても、他のClientにおいて管理しているRP-Sessionはもちろん、IdP側で管理しているOP-Sessionも無効になることはありません。この状況において、Single Sign-Outさせるためには、ログアウトを伝播させていく必要があると思われます。
OpenID Connect Session Management 1.0にでてくるRP-initiated LogoutやFront-Channel Logout/Back-Channel Logoutを組み合わせたら、PR-Sessionの無効化をOP-Sessionの無効化に、OP-Sessionの無効化を他のRP-Sessionの無効化に伝播していくことができると思われます。
まとめ
1st party clientにOAuth/OIDCの流れを組み込むことを考えてみました。
- ブラウザを介した共有の認証UI作る必要ある
- デバイス情報の取得をどうしようか?
- 認可画面を出す必要あるのか?
- scopeそこまで絞れない気がするけど大丈夫?
- どうやってログインセッションを発行しようか?
- どうやってログインセッションを破棄しようか?
実現するためには、いろいろな課題があるように思います。
恐らくこれらの課題には、明確な答えはなく、その時の状況によって臨機応変に対応していくことが求められるでしょう....。
うーむ。やっぱり、最初からIDaaS使っておくのがいいんじゃないかな。
おわり