完全に初心者の趣味です。勤務先や所属する団体の活動とは関係ありません。
tl;dr
OpenID Connect(OIDC) でログインする、Single Page App(SPA) もどきをNode.jsで作りました。
作ったSPAもどきで、Auth0を利用してみました。サインアップから、Auth0を使ったログイン完了まで、初見で12分で終わりました。
一応利用規約も簡単に目を通した。
一番迷ったのは無料サインアップまでのルート。
作ったSPAもどきの解説は後半です。
Google IDでログインするための設定も最後に。
まずは実際に動かしてみる
Node.jsがlocalhostで動く環境が前提です。(localhostじゃないときはlocalhostって書いてある部分を適当に変えてください)
Auth0のサインアップ
Google検索で「Auth0」と入れて、広告の下のリンクをクリックします。
広告のリンクをクリックすると、サインアップページにたどり着きません。
右上の「無料トライアル」をクリックして、GitHubでも、Googleでも、好きなアカウントでサインアップします。
そういえば自分の名前すら入力していない気がする。
サインアップが終わったら、すぐダッシュボードが表示されます。
右上の、「+Create Application」をクリックします。
適当にアプリ名を考えてから、今回は「Regular Web Applications」を選択します。
多分、ここは何を選んでも大丈夫な気がします。
次の画面で、「Quick Start」が出ますが、そこは使わず、その隣の「Settings」タブを開き、
「Client ID」、「Client Secret」を確認します。(後でコピペします。)
下にスクロールしていって、「Application URIs」の中の、「Allowed Callback URLs」に、 http://localhost:3000/ と入力します。
さらに下にスクロールしていって、「Advanced Settings」を開きます。
一番右の「Endpoints」をクリックします。
この中の、「OAuth Authorization URL」と、「OAuth Token URL」を、後でコピペします。
Node.js コードのダウンロードと設定
下記のレポジトリをチェックアウトします。面倒なら、ここからソースのZIPを直接ダウンロードします。
ファイルを解凍したら、ターミナル、もしくはコマンドプロンプトでnpm install を実行します。
処理を待っている間に、app.jsをエディタで開き、3行目から6行目に、先ほどAuth0の設定ページで見つけた、
「OAuth Authorization URL」、「OAuth Token URL」、「Client ID」、「Client Secret」を、それぞれコピペします。
SPAもどきでは、node.js側でセッションを管理しているので、 最初に1回だけnode sqlite_init.js を実行して、データベースを作ります。
node app.js を実行します。
実際にログインしてみる
ブラウザで、 http://localhost:3000 にアクセスします。
上から順に、(1)、(2)のボタンを押していくと、Auth0のログインページに行きます。
「Sign up」から、eメールとパスワードを入力してログインすることもできますし、デフォルトでGoogleログインが使えるように設定されています。
「Accept」を押すと、元のページに戻ってきます。
(3)を押すと、Node.js経由で、Auth0のサーバから、ユーザ情報(いわゆるIDトークン)を取得して、その中身をデコードして表示します。
ここまで実測12分で終わってしまいました。。始めたときは、タイムアタックするつもりなんてなかったので、終わってから時計を見てびっくりしました。最初のサインアップページに迷ったり、利用規約を読んだりしなければ、本当に3分で終わったんじゃないかと思います。
SPAもどきの作り方
作った理由
いわゆる普通のWebサイトで、GoogleやFacebook,Twitterなどを使ったソーシャルログインを実現する例はよく見るのですが、ネイティブアプリでのソーシャルログインは、各サービスがSDKを提供しているため、仕組みがよくわからず、ブラックボックスになってしまっている気がしました。
なので、ネイティブアプリを作るのはちょっと難易度高いですが、静的なHTML+JavaScriptで、サーバとはREST APIで通信して、ネイティブアプリっぽく振る舞う、SPAもどきを作ることで、仕組みを理解しようと思いました。
参考にしたサイト
日本におけるOpenID Connectの重鎮のお二方のブログを参考にアーキテクチャを考えました。いつもお世話になっております。
アーキテクチャ
REST APIっぽく作りたかったので、簡単にできるNode.js + Express構成を使います。
SPA部分は、JavaScriptも含めて1つのHTMLファイルにしています。
(作り始めたときは、一部動的な部分があるかなと、ejsを使いましたが、結果的に完全に静的なページになっています。)
OpenID ConnectでSPAを作る場合、インプリシットフローやハイブリッドフローで、SPAが直接IDトークンを取得するパターンがよく紹介されている訳ですが、IDトークンにもそれなりに個人情報が含まれる場合があることと、実装をシンプルにするために、サーバ、SPA間はセッションIDで管理することにしました。
ログインのフローはこんな感じです。(1)から(3)は、それぞれ実際のSPAもどきを使うときに押すボタンに対応しています。
通常Webサイトであれば、ユーザのログイン状態はセッションIDをCookieに保存することで管理します。
ですが、ネイティブアプリを想定して、セッションIDはAuthorizationヘッダーに入れることにしました。ブラウザ内ではlocalStorageに保存します。
こういう設計で本当に問題ないのかは、識者のアドバイスを頂戴したいところです。私は初心者なので。
各処理の解説
(1) ログインリクエスト
SPAは静的なHTMLで、サイトを開いた時点ではセッションIDは付与していません。
Ajaxで、サーバに対して、セッションIDの払いだしと、IDP(ID Provider:認証サーバ、ここではAuth0)にログインするためのAuthorizationエンドポイントのURLや、パラメータを取得します。
※ ここでAuthorizationエンドポイントのURLを取得しているので、例えばIDPをAuth0からGoogleに切り替える場合も、サーバ側の設定を切り替えるだけで、SPA(HTML/JavaScript)側の修正は不要になります。
ついでに、SPA側でも、CSRFから防御するため、stateという乱数を生成しておきます。
サーバ側では、ここでセッションIDを払い出し、ログインのためのPKCEコードを生成して、各種パラメータとともにSPAに返却します。
PKCEについての説明はこちら
サーバから返ってきたセッションIDは、後で使うのでlocalStorageに保存しておきます。
今回作ったSPAでは、デモ用にセッションIDを表示していますが、通常は隠しておくべきです。
(2) ログインページに接続
(1)でサーバから返ってきた AuthorizationエンドポイントのURLとパラメータに、SPAで生成したStateをくっつけて、アクセスします。
ネイティブアプリであれば、外部ブラウザやCustomTab, SFSafariViewControllerやASWebAuthenticationSessionを使いますが、今回はブラウザなので単純にlocation.hrefで移動することにします。
遷移先で無事ログインが成功すると、元のページに、クエリパラメータ「code」と「state」が付属して返ってきます。
ネイティブアプリであれば、Android App LinksやUniversalLinksを使ってアプリに処理を戻すことが推奨されます。詳細はBCP212/RFC8252を参照してください。
今回作ったSPAでは、エラー処理は省略しています。
(3) 認可コードとセッションID送信
SPAに認可コード(code )とstateが返ってきたので、まず、stateが事前に保存しておいた値と一致するかを検証します。もし一致しなかったら、悪意ある人からの攻撃の可能性があります。
※ といっても、PKCEを使っている限り、その先の処理が成功しないのでユーザ情報の流出などの可能性は低いですが、stateを使わずに、codeが戻ってきたら自動でサーバに送信してログインを試みるような処理をしていると、勝手にログアウトされちゃったりしかねないので、検証処理を入れておいた方がいいと思います。
state が一致しているようなら、サーバに、認可コードを送信します。その際、(1)で保存しておいたセッションIDを、Authorizationヘッダーに付与します。
今回作ったSPAでは、デモのために、(3)の処理を手動で行うようにしていますが、本来であれば、認可コードがIDPから戻ってきてから、Stateを比較し、サーバに送る一連の処理は自動で行うべきです。
認可コードを受信したサーバは、セッションIDと紐付けて保存しておいたPKCEコードと、クライアントID/クライアントシークレット、認可コードをIDP(Auth0)に対して送信します。
すると、IDPから、IDトークンが返ってきます。
IDトークンをデコードすると、ユーザID(sub値)がわかるので、サーバの中では、sub値をキーにしてユーザを管理します。
サーバ内で、セッションIDと、sub値を紐付けてデータベースに保存したら、ログイン完了です。
SPAでは、デモのため、IDトークンの中身を返却しています。
(4) セッションIDの利用
今までの処理で、セッションIDとユーザが紐付いたので、セッションIDが安全に管理されている限り、セッションIDの送り主=そのユーザであるということを前提にサービスを提供します。
今回作ったSPAでは、セッションIDを送ることで、ログイン時にサーバに保存したユーザ情報を取得することができるようにしてみました。
試しにブラウザをリロードして、(4)のボタンを押してみてください。(3)を実行したときと同じ情報が返ってくることが確認できます。
Google IDでログインしてみる。
今度は、Auth0を使わずに、直接Google IDでログインできるようにしてみます。
Google ログインの設定
Google IDでOIDCログインを行うための設定は、Auth0に比べると、少し大変です。
まず、Google Cloud Platformで新しいプロジェクトを作ります。
そして、「認証情報」を開き、「+認証情報を作成」から、「OAuthクライアントID」を選択します。
すると、先に同意画面を設定しろと言われてしまいました。言われたとおり、「同意画面を設定」をクリックします。
User Typeは、企業ユーザなどでない限り、「外部」しか選択できません。
アプリ名など、必要な情報を適宜入力します。
スコープを選択するよう求められますので、最低限`openidだけは指定します。
次の画面で、テストユーザを登録するよう求められますが、登録しなくてもログインだけはできるようです。
仕切り直して、「認証情報」を開き、「+認証情報を作成」から、「OAuthクライアントID」を選択します。
アプリケーションの種類は「ウェブ アプリケーション」としました。
承認済みのリダイレクトURIに、http://localhost:3000 を追加します。
「作成」を押すと、「クライアントID」と「クライアント シークレット」が表示されます。
これを、先ほどダウンロードしてきた、app.jsにコピペします。
ドキュメントを見ても、Googleの「OAuth Authorization URL」、「OAuth Token URL」の設定はどこなのか、サンプル電文以外で見つけることができませんでした。おそらく、OIDC仕様に準拠して、OpenID Configurationを見ろということなんだと思います。(GitHubのソースコードは、元からGoogleの設定にしてありますので、Auth0をまだ試してみていない場合は、そのままで大丈夫です。)
app.js
const OIDC_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
const OIDC_TOKEN_ENDPOINT = "https://www.googleapis.com/oauth2/v4/token"
Google IDでログインしてみる。
設定が終わったら、node app.jsをCtrl+Cで終了してから再起動して、新しいタブを開いて 、http://localhost:3000に再度アクセスして見てください。
(1),(2),(3)と順番にボタンを押していけば、今度はGoogleのIDトークンの中身が取得できました。
ちなみに、「新しいタブを開いて」とお願いした理由は、冒頭で取得したAuth0のセッションが残っていれば、
そっち側のセッションもちゃんと有効で、Auth0で取得したユーザ情報を取得することができます。
GoogleとAuth0で、iss値が異なるのがわかると思います。
セッションDBの中身
今回作ったSPAはデモなので、1つのテーブルですべてのデータを管理しています。PKCEコードとか、ログイン終わったら消してもいいんですけどね。
あと、DBの中を直接見て気づいたんですが、Auth0のsub値には、|(縦棒) が含まれてますね。いまどきいないと思いますが、SQLを生でいじるような人は要注意ですね。
% sqlite3 oidc_db.sqlite ".schema"
CREATE TABLE session (session_id TEXT UNIQUE NOT NULL, pkce_code TEXT, subject_id TEXT, user_info TEXT, active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (DATETIME('now', 'localtime')));
% sqlite3 oidc_db.sqlite "select * from session"
C2WRrorCttmBOuY9g6FXbw|jp(中略)Gv|auth0|610e48bed3fd1200687bb379|{(中略)"iss":"https://oidc-for-spa.jp.auth0.com/",(中略)}|0|2021-08-07 18:38:58
6DvaFpPv5rVUioruwnBXxw|QU(中略)6L|113688351101139242200|{"iss":"https://accounts.google.com",(中略)}|1|2021-08-07 21:49:40
感想
OAuth / OpenID Connect という標準に準拠して各社がサービスを提供してくれているおかげで、最低限の設定変更で、各社のIDaaSを使って、Webやアプリでのログインが実現できることがわかりました。標準化バンザイ。
↧