- フロントエンドとバックエンドに分離している
- フロントエンドはブラウザベースのJavaScriptアプリケーション(要はSingle-page application)
- バックエンドはREST APIによるWebサービスのアプリケーションで、アクセストークンを用いたアクセス制御を行う
トークンまわりをどう実装する?
「OAuth 2.0 for Browser-Based Apps1」という文章を参照してみた。なぜ突然OAuth 2.0?となるがSection 5の「First-Party Applications」に書かれた言葉を借りる。
いわく、
- OAuthはユーザーに代わってサードパーティのアプリケーションがAPIを実行する許可を与える仕組みとして作られた2
- しかしこの仕組みはフロントエンドもバックエンドも同じ提供元の場合にも有用であることがわかっている
そういうわけなので、今回は標準的で広く一般的にも利用されているOAuth 2.0を採用することとする。
続くSection 6「Application Architecture Patterns」では以下のようにアプリケーションをパターン分けし、それぞれどのように構築すればよいか書かれている。
- Browser-Based Apps that Can Share Data with the Resource Server
- JavaScript Applications with a Backend
- JavaScript Applications without a Backend
今回はいわゆるBFF (Backends For Frontends)を持たないSPAを考えているため「JavaScript Applications without a Backend」を参照していく。
アクセストークンはどうやって取得する?
では「OAuth 2.0 for Browser-Based Apps」のSection 6.3「JavaScript Applications without a Backend」を確認していく。
結論から言うと「Authorization Code flow with the PKCE extension」を使いなさいとのこと。OAuth 2.0のAuthorization Code flowを選択しつつ、クライアント認証なしにトークンを交換できてしまうため拡張仕様のPKCEで認可コードの悪用を防ごう3、ということだろう。
まずは載っている構成図を元に今回のアプリケーション構成を書き直してみた。
- 静的Webサイト(SPA)をブラウザに表示する
- SPAと認可サーバーの間でAuthorization Code Flow w/PKCEによるやり取りを行う
- SPAから認可サーバーに認可リクエストを送る
- 認可サーバーはユーザーを認証する
- 認可サーバーからSPAに認可コードが渡される
- SPAから認可コードを添えて認可サーバーにトークンリクエストを送る
- 認可サーバーからSPAにアクセストークンが渡される
- SPAはアクセストークン付きで保護されたバックエンドのAPIにアクセスする
これでアクセストークンをどう取得すればいいかはわかった。これにあたりやらなければいけないことがあるのでまとめておく。まずはSPA側。
- Authorization Code flow w/PKCEを使いアクセストークンを取得する
- CSRFを防ぐ
- PKCEだけでも結果的に防げるが
state
パラメーターも利用する
- PKCEだけでも結果的に防げるが
続いて認可サーバーでやらなければいけないこと。
- リダイレクトURIの検証は完全一致で行う
- PKCEを必ずサポートする
- CORSに対応する(トークンリクエストがPOSTメソッドのため)
トークンはどこに保存する?
引き続き「OAuth 2.0 for Browser-Based Apps」のSection 6.3「JavaScript Applications without a Backend」を確認していく。
- 適切なブラウザーAPIを使いできるだけ安全に保存せよ
- 本文章発行時点で完全に安全な方法でトークンを保存できるブラウザーAPIはない
はぐらかされてしまった…。そういうわけで、別の拠り所「Webアプリケーションのセッション管理にJWT導入を検討する際の考え方」に気持ちを切り替えていく。
このブログは「トークンをどこに保存するのか」と「JWTを採用する場合」に話を整理した上で展開されていく。単純化するため今回のアプリケーションではJWTは使わず無意味な文字列で構成されたトークン4を採用する。
そうすると「Cookie vs Token in WebStorage」の章のみを読めばとりあえずよくなり、結論を以下に引用する。
- SPAからのAPIアクセスに利用する期限の短いアクセストークンはリスク受容して WebStorage でもいいかな?
- それを生成するために必要なやつは Cookie に保存してそこからアクセストークン払い出そうかな?
- Cookie は常に送られるしサイズとかあるみたいなのででかいのは入れられないかもな... WebStorage に入れといた値と Cookie の値の組み合わせをうまくできないかな?
いったんはこれで進めてみる。アクセストークンはWeb Storageに保存する、ただし盗まれた際のリスクを考えて有効期限は短くしておく。後ろふたつは明言されていないがここではリフレッシュトークンのことを指していると解釈した。リフレッシュトークンに関しては先ほどまで参照していた「OAuth 2.0 for Browser-Based Apps」に言及があるため再び宗旨替えしていく。
リフレッシュトークン
リフレッシュトークンはアクセストークンの期限が切れた際に、新しいアクセストークンをもらうために使うトークンである。
今回はアクセストークンの有効期限を短く設定するため、リフレッシュトークンを用意しないとユーザーは頻繁に「Authorization Code Flow w/PKCE」でアクセストークンを再取得することになり、場合によっては短時間に何度も認可リクエスト時の認証を強いられることになってしまう。
さて「OAuth 2.0 for Browser-Based Apps」のSection 8には、まさに「Refresh Tokens」という章がありリスクと対策について書かれている。
- ブラウザベースのJavaScriptアプリケーションではリフレッシュトークンが漏れる様々な機会がある
- リフレッシュトークンが漏れると新しいアクセストークンを取得され続ける可能性がある
続いて対策について確認していく。「OAuth 2.0 Security Best Current Practice5」を参照せよとしつつ「特に」ということで認可サーバーでの対策が挙げられている。
- ローテーションするか、Sender Constrained Tokenにせよ
- 最大有効期限を設ける、または一定時間使われなかったら期限切れにせよ
- ローテーションされたリフレッシュトークンの有効期限を延長しない
今回は具体例も書かれているリフレッシュトークンをローテーションする方法を採用する。
- 認可サーバーがトークンを発行する
- アクセストークン:1時間有効
- リフレッシュトークン:24時間有効
- 1時間後、アクセストークンが期限切れになる
- SPAはリフレッシュトークンを使って新しいトークン取得を行う
- 認可サーバーがトークンを発行する
- 新しいアクセストークン:1時間有効
- ローテーションされたリフレッシュトークン:23時間有効(最初に設定された期限を延長しない)
これでリフレッシュトークンが漏れても、アクセストークンを取得され続けるという悪夢は回避できた。またローテーションすることでトークンが盗まれたことに気づくこともできる。
- リフレッシュトークンが攻撃者に盗まれる
- 攻撃者がリフレッシュトークンを使い新しいアクセストークンを取得
- リフレッシュトークンがローテーションされる
- 被害者が(古い)リフレッシュトークンを使い新しいアクセストークンを取得しようとする
- 認可サーバーはローテーションしたはずなのに古いリフレッシュトークンが使われるのは怪しいということでトークンの流出を疑う
ではトークンをどこに保存すればいいのかについてまとめてみる。
- アクセストークンはWeb Storageに保存する
- リフレッシュトークンの利用を採用しWeb Storageに保存する
- アクセストークンの有効期限は短くしておく
- リフレッシュトークンをローテーションすることでトークンが盗まれたことに気づけるようにする
- リフレッシュトークンに最大有効期限を設けることで乗っ取られる期間を一定に抑える
実装していく
どう実装していくかの方針は決まった。改めて再確認する。
- 「Authorization Code Flow w/PKCE」を使いアクセストークンを取得する
- SPAはトークンをWeb Storageに保存する
- 認可サーバーはリダイレクトURIを完全一致で検証する
- 認可サーバーはCORSに対応する(トークンリクエストがPOSTメソッドのため)
- 認可サーバーはアクセストークンの有効期限を短くする
- 認可サーバーはリフレッシュトークンを発行する
- 新しいアクセストークンを発行した場合はリフレッシュトークンも新しくする(ローテーションする)
- 最初に発行したリフレッシュトークンの有効期限を延長しない
Auth0使ってみた
『自分で作成しなければならないと思っていた認証画面を他に任せる…そんな画期的なシステム6「Auth0」』を使って「シュッ」と実装していく。
クイックスタートがあり、その中の「Single-Page App」を見ながら進めていったらいつの間にか実装できていた。今回は特定のフレームワークの知識がなくても読めるようにVanilla JSで実装した。
m28dev/auth0-spa-demo (github.com)
実装するにあたりポイントになると思った点を紹介する。
Auth0の設定
ApplicationとAPI、ふたつの設定を作成する必要がある。
まずAPIの設定を作成する。これがアクセストークンに関する設定となる。クイックスタートと違う点としてリフレッシュトークンを利用するため「Access Settings」の「Allow Offline Access」を有効にした。
続いてApplicationの設定を作成する。「Single Page Application」として作成し、最低限「Application URLs」の「Allowed Callback URLs」と「Allowed Web Origins」を入力しておく。
今回はこんな感じ。
設定項目 | 設定値 |
---|---|
Allowed Callback URLs | http://localhost:8080/callback |
Allowed Web Origins | http://localhost:8080 |
SPA(フロントエンド)の実装
クイックスタートと違う点として、リフレッシュトークン利用のためuseRefreshTokens
をtrue
と設定している。
// auth0-spa-jsの設定
const config = {
domain: '<YOUR_AUTH0_DOMAIN>',
client_id: '<YOUR_CLIENT_ID>',
redirect_uri: 'http://localhost:8080/callback',
useRefreshTokens: true,
audience: '<YOUR_AUDIENCE>'
};
let auth0 = null;
// ページロード時にauth0-spa-jsクライアントを初期化
window.addEventListener('load', async () => {
auth0 = await createAuth0Client({ ...config });
});
API(バックエンド)の実装
クイックスタートになかったアクセストークンの中からユーザー情報を取ってくる処理を追加してみた。7
/*
* express-jwtミドルウェアが`req.user`にトークンの内容を設定してくれる
* sub = subject = 認証されたユーザーは誰?が設定されている
*/
app.get('/api/message', cors(corsOptions), checkJwt, (req, res) => {
res.json({
to: req.user?.sub,
message: 'It\'s a secret from our classmates, okay?'
});
});
また今回はユーザー情報を取得しているだけで、ユーザーごとにレスポンスの内容を変えるといったような実装はできていない。これはユーザー登録処理を併せて考える8必要があると思うがそれはまた今度。
方針通りに実装できたか?
決めた方針通りに実装できたか確認してみる。結論から言うと問題なく実装できていた。
「Authorization Code Flow w/PKCE」を使いアクセストークンを取得する
ブラウザの開発者ツールでトークン取得までの通信を眺めてみた。以下の通り「Authorization Code Flow」を使っていた。
- 認可リクエストで
response_type=code
を送っている
またPKCEも使っていた。
- 認可リクエストで
code_challenge
とcode_challenge_method
を送っている - トークンリクエストで
code_verifier
を送っている - 認可サーバー(Auth0)が適切にPKCEを実装しているかどうかは…まあ信じましょう
SPAはトークンをWeb Storageに保存する
auth0-spa-jsのREADMEによるとデフォルトではメモリ上に保存されるとのこと。ページの再読み込み程度でトークンが失われてしまいそうだが、さすがにカバーする仕組みが用意されている。
- ページが再読み込みされた
- ログイン済みかどうかをCookieからチェックする
-
auth0.is.authenticated
または_legacy_auth0.is.authenticated
がtrue
ならログイン済みと判断
-
-
iframe
を使い裏側でAuthorization Code Flowによるトークン取得を行う- OpenID Connectの
prompt=none
を使いログイン画面が出ないようにしている - ユーザーがAuth0からログアウトしていた場合はトークンの取得に失敗し、ログイン済みチェック用のCookieを削除して終了
- OpenID Connectの
もちろんページ再読み込み等がなくリフレッシュトークンがメモリ上に残っている場合は、リフレッシュトークンを使い新しいアクセストークンを取得していた。リフレッシュトークンの期限が切れていた場合は先ほどのiframe
を使った一連の流れが行われる。
方針とは違うがUXに問題はないし、むしろこの方がいい。
認可サーバーはリダイレクトURIを完全一致で検証する
前述のとおり、ワイルドカードなどは使用せず登録しているためAuth0は完全一致で検証してくれる。
認可サーバーはCORSに対応する(トークンリクエストがPOSTメソッドのため)
Auth0はCORSに対応していて「Allowed Callback URLs」に設定したURLがオリジンとして許可される。
認可サーバーはアクセストークンの有効期限を短くする
APIの設定でいくらでも短くすることが可能。
認可サーバーはリフレッシュトークンを発行する
特に以下の方針で発行したいとしていた。
- 新しいアクセストークンを発行した場合はリフレッシュトークンも新しくする(ローテーションする)
- 最初に発行したリフレッシュトークンの有効期限を延長しない
Applicationの設定でどちらも対応が可能。
- 「Refresh Token Rotation > Rotation」にてリフレッシュトークンのローテーションが設定できる
- 「Refresh Token Expiration > Absolute Expiration」にて有効期限を延長しない設定ができる
またAuth0のドキュメント「Refresh Token Rotation (auth0.com)」によるとトークンが盗まれた疑いがある場合に気づけるようになっているようだった。
感想
「保護されたAPIにアクセスする」という部分にフォーカスして調べてみた。
- 長々と書いたけど素直にBFFを用意して心配事を減らした方が結局シンプルなのでは
- 意地でもBFFを用意したくない時はAuth0とauth0-spa-jsに頼ると自然にベストプラクティスに乗っかれるので、みんな使えばいいと思った
- 記事の書きやすさのため「OAuth 2.0 for Browser-Based Apps」や「OAuth 2.0 Security Best Current Practice」の内容を敢えて端折っているため全部を自分でやりたい人は原文を読み込んでほしい
-
記事作成時点の版はdraft-ietf-oauth-browser-based-apps-08 ↩
-
この動画がわかりやすい。https://youtu.be/iGFy1xHGGx4?t=131 ↩
-
認可コード悪用については「OAuth徹底入門」が詳しい。OAuth徹底入門 セキュアな認可システムを適用するための原則と実践(Justin Richer Antonio Sanso 須田 智之 Authlete, Inc.)|翔泳社の本 (shoeisha.co.jp) ↩
-
この場合のトークンの検証には例えばトークンイントロスペクション(rfc7662 (ietf.org))という仕様がある。雑に言うと認可サーバーにアクセストークンを検証するAPIを用意する感じ。 ↩
-
記事作成時点の版はdraft-ietf-oauth-security-topics-18 ↩
-
「マンガでわかる!Auth0誕生の秘密とは」より。https://www.amazon.co.jp/dp/B08MZRCLTK/ ↩
-
つまりアクセストークンは実はJWTになっている。まあ秘匿情報とかは入ってないし、いいでしょ。 ↩
-
フロントで受け取ったID Tokenをバックエンドに渡してユーザー登録する、社内サービスなので事前にユーザー登録済み、とかそんな感じ? ↩