初見3分(本当は12分)でAuth0を使ったOIDCログインを実装するまで

:::note info
完全に初心者の趣味です。勤務先や所属する団体の活動とは関係ありません。
:::

## tl;dr

* OpenID Connect(OIDC) でログインする、Single Page App(SPA) もどきをNode.jsで作りました。
* 作ったSPAもどきで、Auth0を利用してみました。サインアップから、Auth0を使ったログイン完了まで、初見で12分で終わりました。  
 * 一応利用規約も簡単に目を通した。
 * 一番迷ったのは無料サインアップまでのルート。
* 作ったSPAもどきの解説は後半です。
 * Google IDでログインするための設定も最後に。


## 試してみる

Google IDログインですが、Glitchにもサンプルをアップしました。

https://oidc-for-spa.glitch.me/

## まずは実際に動かしてみる

Node.jsがlocalhostで動く環境が前提です。（localhostじゃないときはlocalhostって書いてある部分を適当に変えてください）

### Auth0のサインアップ

Google検索で「Auth0」と入れて、**広告の下のリンク**をクリックします。
![Google-auth0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/11d5e0d6-6e9c-1c80-91f8-a279625b6bb1.png)

広告のリンクをクリックすると、サインアップページにたどり着きません。

右上の「無料トライアル」をクリックして、GitHubでも、Googleでも、好きなアカウントでサインアップします。
そういえば自分の名前すら入力していない気がする。

![Auth0-trial.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/7b3e43e6-18ae-30de-836b-5aaaf5cf3cc8.png)

サインアップが終わったら、すぐダッシュボードが表示されます。
右上の、「＋Create Application」をクリックします。

![Auth0-createApp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/b96cc0f3-1058-2266-63dd-f51f3c2d923c.png)

適当にアプリ名を考えてから、今回は「Regular Web Applications」を選択します。
多分、ここは何を選んでも大丈夫な気がします。
![Auth0-createApp2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/f10ec8d8-f3fd-6d62-65ef-b0b559391de8.png)

次の画面で、「Quick Start」が出ますが、そこは使わず、その隣の「Settings」タブを開き、
「Client ID」、「Client Secret」を確認します。（後でコピペします。）
![Auth0-BasicInfo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/da6a8327-90b6-c3c0-348c-600cdea289c2.png)

下にスクロールしていって、「Application URIs」の中の、「Allowed Callback URLs」に、 ```http://localhost:3000/``` と入力します。
![Auth0-AppURI.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/ac092187-85ff-c8e1-cbc3-e4522b9342a9.png)

さらに下にスクロールしていって、「Advanced Settings」を開きます。
一番右の「Endpoints」をクリックします。
この中の、「OAuth Authorization URL」と、「OAuth Token URL」を、後でコピペします。
![Auth0-endpoints.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/0c5f4778-9dfa-de7f-e159-3c010534b5ac.png)

### Node.js コードのダウンロードと設定

下記のレポジトリをチェックアウトします。面倒なら、[ここからソースのZIPを直接ダウンロード](https://github.com/kkoiwai/OIDC_for_SPA#:~:text=with%20GitHub%20Desktop-,Download%20ZIP,-Latest%20commit)します。

https://github.com/kkoiwai/OIDC_for_SPA

ファイルを解凍したら、ターミナル、もしくはコマンドプロンプトで```npm install``` を実行します。

処理を待っている間に、```.env.example```をエディタで開き、先ほどAuth0の設定ページで見つけた、
「OAuth Authorization URL」、「OAuth Token URL」、「Client ID」、「Client Secret」を、それぞれコピペしたら、```.env```として保存します。

![Auth0-editor.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/d7a1d0a2-da34-17fc-4073-c8d08271a90f.png)

### 起動

```node app.js``` を実行します。



### 実際にログインしてみる

ブラウザで、 ```http://localhost:3000``` にアクセスします。

上から順に、（1）、（2）のボタンを押していくと、Auth0のログインページに行きます。
![Auth0-demo1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/d5d2e2fd-6dc2-d1d1-f55c-d974838b3096.png)

「Sign up」から、eメールとパスワードを入力してログインすることもできますし、デフォルトでGoogleログインが使えるように設定されています。
![Auth0-demo2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/5351d6cc-094c-3964-60ec-8e2ce107507a.png)

「Accept」を押すと、元のページに戻ってきます。
![Auth0-demo3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/235ae55f-9efc-6e45-322f-cc249efe4afe.png)

(3)を押すと、Node.js経由で、Auth0のサーバから、ユーザ情報（いわゆるIDトークン）を取得して、その中身をデコードして表示します。
![Auth0-demo5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/a340b22e-cbf1-89bc-fdec-a9ea6bc71ea5.png)


ここまで実測12分で終わってしまいました。。始めたときは、タイムアタックするつもりなんてなかったので、終わってから時計を見てびっくりしました。最初のサインアップページに迷ったり、利用規約を読んだりしなければ、本当に3分で終わったんじゃないかと思います。


## SPAもどきの作り方

### 作った理由

いわゆる普通のWebサイトで、GoogleやFacebook,Twitterなどを使ったソーシャルログインを実現する例はよく見るのですが、ネイティブアプリでのソーシャルログインは、各サービスがSDKを提供しているため、仕組みがよくわからず、ブラックボックスになってしまっている気がしました。
なので、ネイティブアプリを作るのはちょっと難易度高いですが、静的なHTML+JavaScriptで、サーバとはREST APIで通信して、ネイティブアプリっぽく振る舞う、SPAもどきを作ることで、仕組みを理解しようと思いました。

### 参考にしたサイト

日本におけるOpenID Connectの重鎮のお二方のブログを参考にアーキテクチャを考えました。いつもお世話になっております。

https://ritou.hatenablog.com/entry/2019/12/03/104500

https://ritou.hatenablog.com/entry/2020/03/12/114702

https://www.slideshare.net/kura_lab/openid-connect-and-login-cycle-for-android-apps

https://techblog.yahoo.co.jp/web/auth/id_federation_impl_patterns/


### アーキテクチャ

REST APIっぽく作りたかったので、簡単にできるNode.js + Express構成を使います。
SPA部分は、JavaScriptも含めて1つのHTMLファイルにしています。
（作り始めたときは、一部動的な部分があるかなと、ejsを使いましたが、結果的に完全に静的なページになっています。）

OpenID ConnectでSPAを作る場合、インプリシットフローやハイブリッドフローで、SPAが直接IDトークンを取得するパターンがよく紹介されている訳ですが、IDトークンにもそれなりに個人情報が含まれる場合があることと、実装をシンプルにするために、サーバ、SPA間はセッションIDで管理することにしました。

ログインのフローはこんな感じです。(1)から(3)は、それぞれ実際のSPAもどきを使うときに押すボタンに対応しています。

![Auth0-OIDC-flow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/45daad2d-2917-497f-2077-1f5e8eb81276.png)


通常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についての説明はこちら

https://qiita.com/TakahikoKawasaki/items/00f333c72ed96c4da659

サーバから返ってきたセッションIDは、後で使うのでlocalStorageに保存しておきます。
今回作ったSPAでは、デモ用にセッションIDを表示していますが、通常は隠しておくべきです。

#### (2) ログインページに接続

(1)でサーバから返ってきた AuthorizationエンドポイントのURLとパラメータに、SPAで生成したStateをくっつけて、アクセスします。
ネイティブアプリであれば、外部ブラウザやCustomTab, SFSafariViewControllerやASWebAuthenticationSessionを使いますが、今回はブラウザなので単純にlocation.hrefで移動することにします。

遷移先で無事ログインが成功すると、元のページに、クエリパラメータ「```code```」と「```state```」が付属して返ってきます。
ネイティブアプリであれば、Android App LinksやUniversalLinksを使ってアプリに処理を戻すことが推奨されます。詳細は[BCP212/RFC8252](https://www.rfc-editor.org/rfc/rfc8252.html)を参照してください。
今回作った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」を選択します。

![GCP-createOAuth.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/690574d4-d813-c3d4-f469-e82343ff56d3.png)

すると、先に同意画面を設定しろと言われてしまいました。言われたとおり、「同意画面を設定」をクリックします。
![GCP-consenterror.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/97ef8412-69cf-8eb8-67cb-9d5d36c20cbe.png)

User Typeは、企業ユーザなどでない限り、「外部」しか選択できません。
![GCP-consent.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/6ea7a5b8-eb4f-530c-0ea3-fa13f958f481.png)

アプリ名など、必要な情報を適宜入力します。
![GCP-consent-appinfo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/bac635d7-aa86-b555-4865-260906d3e42c.png)

スコープを選択するよう求められますので、最低限```openid``だけは指定します。
次の画面で、テストユーザを登録するよう求められますが、登録しなくてもログインだけはできるようです。
![GCP-scope.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/b06075f1-1d25-e920-d11d-9c489c84e0cf.png)

仕切り直して、「認証情報」を開き、「＋認証情報を作成」から、「OAuthクライアントID」を選択します。

アプリケーションの種類は「ウェブ アプリケーション」としました。
承認済みのリダイレクトURIに、```http://localhost:3000``` を追加します。
![GCP-createClientID.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/4b98e4af-5c4c-8cb3-7de2-4ff2ced0855f.png)

「作成」を押すと、「クライアントID」と「クライアント シークレット」が表示されます。
これを、先ほどダウンロードしてきた、```app.js```にコピペします。
![GCP-clientsecret.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/cee8b0c6-609a-3244-9d77-beaf49ec63b4.png)

ドキュメントを見ても、Googleの「OAuth Authorization URL」、「OAuth Token URL」の設定はどこなのか、サンプル電文以外で見つけることができませんでした。おそらく、[OIDC仕様](https://openid.net/specs/openid-connect-discovery-1_0.html)に準拠して、[OpenID Configuration](https://accounts.google.com/.well-known/openid-configuration)を見ろということなんだと思います。(GitHubのソースコードは、元からGoogleの設定にしてありますので、Auth0をまだ試してみていない場合は、そのままで大丈夫です。）

![GCP-editor.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/b12f18f3-3a7c-5c65-cc05-6c37229ef4aa.png)

```js: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トークンの中身が取得できました。

![GCP-userinfo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/1e9a378b-10e2-d0cc-2170-017d0119b709.png)

ちなみに、「新しいタブを開いて」とお願いした理由は、冒頭で取得したAuth0のセッションが残っていれば、
そっち側のセッションもちゃんと有効で、Auth0で取得したユーザ情報を取得することができます。
GoogleとAuth0で、```iss```値が異なるのがわかると思います。

![Auth0-userinfo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135431/8559e150-a8da-8be7-0540-9115c5f87831.png)

## セッションDBの中身

今回作ったSPAはデモなので、1つのテーブルですべてのデータを管理しています。PKCEコードとか、ログイン終わったら消してもいいんですけどね。
あと、DBの中を直接見て気づいたんですが、Auth0の```sub```値には、```|```(縦棒) が含まれてますね。いまどきいないと思いますが、SQLを生でいじるような人は要注意ですね。

```sh
% 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やアプリでのログインが実現できることがわかりました。標準化バンザイ。