完全に初心者の趣味です。勤務先や所属する団体の活動とは関係ありません。
tl;dr
- OpenID Connect(OIDC) でログインする、Single Page App(SPA) もどきをNode.jsで作りました。
- 作ったSPAもどきで、Auth0を利用してみました。サインアップから、Auth0を使ったログイン完了まで、初見で12分で終わりました。
- 一応利用規約も簡単に目を通した。
- 一番迷ったのは無料サインアップまでのルート。
- 作ったSPAもどきの解説は後半です。
- Google IDでログインするための設定も最後に。
試してみる
Google IDログインですが、Glitchにもサンプルをアップしました。
まずは実際に動かしてみる
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
を実行します。
処理を待っている間に、.env.example
をエディタで開き、先ほどAuth0の設定ページで見つけた、
「OAuth Authorization URL」、「OAuth Token URL」、「Client ID」、「Client Secret」を、それぞれコピペしたら、.env
として保存します。
起動
node app.js
を実行します。
実際にログインしてみる
ブラウザで、 http://localhost:3000
にアクセスします。
上から順に、(1)、(2)のボタンを押していくと、Auth0のログインページに行きます。
「Sign up」から、eメールとパスワードを入力してログインすることもできますし、デフォルトでGoogleログインが使えるように設定されています。
(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をまだ試してみていない場合は、そのままで大丈夫です。)
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やアプリでのログインが実現できることがわかりました。標準化バンザイ。