l0w.dev

React Hooks で OAuth2 する


最近は React で SPA を作る勉強をしています。

自分が興味があるのは OAuth2 / OpenID Connect できちんとユーザーの認証・承認ができて、 GitLab Pages や GitHub Pages、 Azure Storage や AWS S3 といった スタティック Web サイトのプラットフォームにファイルを置くだけで動くような SPA の作り方です。

またこのごろは React Hooks が話題なので、これを使った OAuth2 ライブラリがないか探したところ react-oauth2-hook というのを見つけました。 OAuth2 をやる React コンポーネントの設計としてなかなか参考になりましたので、 GitLab でユーザー認証する検証用アプリを作って GitLab Pages にデプロイしてみました。

上記デモページでログイン・ログアウトを試すには gitlab.com にユーザー登録している必要があります。 read_user 権限のアクセスの確認がされますので承認してください。 gitlab.com より取得した情報がどこかに送信されることはありません。

HashRouter + BrowserRouter ルーティング

このアプリでは Navigation: のリンクを使って React Router v4 の HashRouter の動作を試すことができます。 これは #/A/A/A/B/B/B/C/C/C のような URL のハッシュ部分でファイル階層を表現するものです。

GitLab Pages や GitHub Pages では、 サーバのファイルシステムに存在しないパスへのリクエストはすべて 404 Not Found になるため通常は HashRouter を使うことになります。 SPA 向けに 404 を返さないようにする機能についての要望や議論は GitLab と GitHub の両方にありますが、 まだ具体的な解決の動きには至ってはないようです。

その一方で、このアプリでは OAuth2 の認証に implicit grant flow を使っています。 アクセストークンの受け渡しにハッシュが使われるため HashRouter と共存ができません。 そこで実ファイルパスである /callback をコールバック URL に設定して BrowserRouter で受けることにしました。

結果として、 メインアプリコンポーネント は次のような BrowserRouter と HashRouter のハイブリッド構成となりました。

// App component
// Hybrid path routing with BrowserRouter (/callback) and HashRouter (/#/...)
export default () =>
  <BrowserRouter basename={process.env.PUBLIC_URL}>
    <Switch>
      <Route exact path="/callback" component={OAuthCallback} />
      <Route exact path="/" render={() => <HashRouter><AppRoot /></HashRouter>} />
    </Switch>
  </BrowserRouter>

さらに GitLab Pages で /callback へのリクエストを正しく受けるために GitLab CIindex.htmlcallback にコピーしています。

  script:
    - test -n "$PUBLIC_URL" || export PUBLIC_URL=/${CI_PAGES_URL#https://*/}
    - yarn install
    - yarn build
    - rm -rf public
    - mv build public
    - cp public/index.html public/callback
    - find public

こういう構成が実現可能かどうかは当初不明だったのですが、今回動作が確認できてよかったです。 たぶん GitHub Pages でもいけると思います。

react-oauth2-hook の認証・承認フロー

react-oauth2-hook の特徴のひとつに、 OAuth2 の認証・承認フローがアプリ本体とは別のブラウザタブ・ウィンドウを開いて進行するというものがあります。 つまりログイン・ログアウトの動作がアプリ本体の状態 (HashRouter のページの階層など) に影響しません。

この方式なら state パラメータにアプリ状態を埋め込んだりする必要がなくなりますし、 ユーザーにとってもログイン・ログアウトの境界でのアプリの中断・ページ遷移がなくなり、 アクセストークンの更新もバックグラウンドでできるようになるため、なかなかよいと思いました。

ただし、ログイン中であることを示すアクセストークンはブラウザのローカルストレージで保持しているため、 複数のタブ・ウィンドウのセッションで同じアプリを開いていた場合、 ひとつのセッションのログイン・ログアウトがただちに他のセッションにも反映されることになります。 これは Web アプリとしてはあまり一般的な挙動ではないため、問題になるかもしれません。

SPA の OAuth2 のセキュリティ

SPA で OAuth2 といえば implicit grant flow でしたが、 最近ではセキュリティの懸念により、すっかり非推奨扱いとなってしまったようです。

そのような状況の中で implicit grant flow を実装している react-oauth2-hook の作者も security considerations にいろいろ書いてくれていますが、実際どういう攻撃やリスクがあってどのような緩和策があるのか、 不勉強なためなかなか全容が把握できていません。

とりあえずそこに書いてある X-Frame-Options については GitLab Pages や GitHub Pages ではいじる方法がないため、 そもそも OAuth2 するような SPA には使っちゃいかんのだろうなと想像しています。 よく比較にあげられる Netlify ならそういうヘッダも設定できるし rewrite 機能もあって前述の 404 Not Found 問題にもうまく対処できるので、よいのかもしれません。

結局のところ現時点では、安全に OAuth2 できる SPA を作りたければ、 アプリ専用のバックエンドサービスを追加してそこで authorization code grant flow をやるしかなさそうと思っています。 いわゆる Backend For Frontend を考えることになりますが、 フロントエンドの JavaScript だけで済んでいたのに比べるとはるかにややこしくなるため、 アプリ開発の敷居がだいぶ高くなります。

フロントエンド・バックエンドともに、様々な言語やフレームワーク、クラウドサービスの選択肢があり、 自分にとってベストなスタックの探求と試行錯誤がしばらく続きそうです。