Auth0から内製認証基盤への移行における、フロントエンドライブラリの内製化について

ogp

はじめに

こんにちは、ソフトウェアエンジニアのchocoです。

前回の大山の記事では、認証基盤をAuth0から内製基盤に移行した話をご紹介しました。 今回はその中からフロントエンド領域にフォーカスを当て、内製化によって何が解決できたか、ライブラリ開発を中心にどのような設計上の判断をしたかをご紹介します。

認証状態を管理するライブラリ

Auth0を利用している時は、auth0-reactauth0-spa-jsといったライブラリを使ってログインフローを提供していました。

これらは、Auth0のユニバーサルログインやAuth0のバックエンドを前提に動くSDKです。

auth0-reactは、大まかに以下のような役割や機能を持っています。

  • 認証状態をReact Contextとして供給するProvider
  • 認証状態に応じて画面遷移を制御するHOC (withAuthenticationRequired等)
  • アクセストークンやユーザーのメタデータ取得用のカスタムフック

実際のAPIリクエストやキャッシングは、auth0-spa-jsが担っています。

ログイン画面やバックエンドサービスを内製化したことで、これらのSDKもそのままでは使えなくなるため、合わせて内製のライブラリを用意しました。

開発の方針

開発を進めていく上で二点意識しました。

  1. 実装対象を絞ること
  2. 既存SDKのAPIと挙動を踏襲すること

enechainでは、auth0-reactから利用している関数や参照している変数はどのアプリケーションでもおおよそ同じであり、前述したような主要機能を使っているケースが大半でした。

そこでまずは、移行対象の機能を絞り込みました。

次に意識したのは「既存SDKのAPIと挙動を踏襲すること」という点です。

新しいライブラリへ移行するアプリケーション数は合計で15個ありました。

全てのアプリケーションで利用している auth0-react / auth0-spa-js の機能は事前に洗い出していましたが、調査後に新しい依存が増える可能性まではゼロにできません。

特にキャッシング挙動のような内部の深い部分は、後から挙動に差異が出るとリスクが大きく、検知も修正も難しくなります。そのため、auth0-spa-js が持つようなキャッシング挙動はそのまま踏襲することを優先し、最適化は既存の挙動を変えない範囲で進める方針にしました。具体的には、ライブラリ内部のキャッシュ実装に強く依存した実装をしているアプリケーションがありました。そのため、キャッシュのデータ構造は踏襲し、新ライブラリでは後述するcacheTTL オプションを実装して、より柔軟なキャッシュ管理をできるようにしました。

振る舞いを変えないことで、レビュワーの認知負荷を軽減して既存のアプリケーションの挙動を変えてしまう恐れがないかという点に焦点を当ててもらうというのも意識したポイントでした。

また導入先のプロダクトには、重要な機能リリースが迫っているプロダクトもあり可能な限り移行へのコストがかからないようにすることも意識しました。

設計について

ここからは、具体的な設計について触れていきたいと思います。

今回実装したライブラリの役割を整理すると、1. 認証状態を判定し画面を描画すること。2. アクセストークンを取得すること、です。前者はReactとの統合を担うUI層、後者は認証ロジック層として分類できます。

この分離は、UI層に AuthProvider、認証ロジック層に AuthClient という2つのモジュールを置く形で実装しました。

AuthProvider は、React Contextとして認証状態を保持し、useAuth フックを通じて利用側に提供しています (状態の具体については後述します)。

AuthClient には、トークンの発行・更新やユーザー情報の取得、ログイン画面へのリダイレクトといった処理をまとめており、こちらはReactには依存しない純粋なクラスにしました。

利用側からは AuthClient を直接触らず、すべて useAuth 経由でアクセスする想定です。

利用側のコードは大まかには次のような形になります。

<AuthProvider options={...}>
  <App />
</AuthProvider>

function Header() {
  const { isAuthenticated, isLoading, user, logout } = useAuth()
  if (isLoading) return <Spinner />
  return isAuthenticated
    ? <UserMenu user={user} onLogout={logout} />
    : <LoginButton />
}

依存方向は AuthProvider から AuthClient への一方向のみで、逆方向の参照は持たせていません。これは、認証ロジック層をReactのレンダリングサイクルから切り離しておきたかったためです。単体テストから AuthClient を直接呼び出すような使い方もできるようになっています。

設計で意識したポイント

意識したのは主に以下の5点です。

  1. 認証状態の設計
  2. インターフェースの最小化
  3. 依存ライブラリの最小化
  4. キャッシュ制御の柔軟化
  5. 負荷対策

認証状態の設計

認証状態は、 AUTHENTICATED / UNAUTHENTICATED / LOADING / ERROR の4つに整理しました。

当初はややナイーブな設計になっており、扱いづらさが出てきたという経緯があります。最初のうちは、認証状態を isAuthenticated/isLoggingOut/isLoading といった独立したbooleanで持っていました。

ただ設計を進めていくと、「認証済みだけどログアウト中」「ログイン中だけどロードしていない」といった存在しないはずの組み合わせが型として表現できてしまう点が気になりました。利用側でも isLoading && !isLoggingOut のような条件分岐が増えていき筋が悪いです。

そこで独立したフラグから単一のenumに切り替え、AUTHENTICATED / UNAUTHENTICATED / LOADING / ERROR の4状態を排他的に持つ形にしました。状態が1つと決まるので、利用側は isAuthenticated / isLoading を素直に分岐に使えるようになります。

インターフェースの最小化

キャッシュTTL、single-flight、トークン更新の閾値といった通信層の細かな振る舞いは外に出さず、利用側に公開するのは isAuthenticated, logout 等の限られたインターフェースだけにしました。

境界を狭く保っておくことで、利用側が誤った使い方をしてリクエストを増やしてしまう余地を減らせますし、後から AuthClient 側の実装を差し替えたくなったときの影響範囲もライブラリ内に閉じ込めやすくなります。

こうした割り切りができるのは、利用者のユースケースが見えている内製ライブラリの強みでもあります。Auth0 のような汎用サービスの SDK は、様々な要望に応えるためにオプションや設定項目が網羅的になりがちで、特定のユースケースに最適化したシンプルなインターフェースには寄せづらい部分です。

依存ライブラリの最小化

通信層は、axiosやreact-queryといったネットワーク/データフェッチ系の依存を挟まず、ブラウザ標準の fetch で書いています。認証ライブラリは15個のアプリケーションに配布される前提だったため、利用側に特定の通信ライブラリを引き連れていくことを避けたかった、というのが理由です。エンドポイントの数も限られており、リトライ等の機構を AuthClient の内側に閉じて自前で書ける範囲に収められるという見通しもありました。

この判断の裏返しとして、ライブラリを使っていれば自動的に得られていたであろうキャッシュやリクエストの重複排除は、自分たちで設計する必要が出てきます。次に挙げる「キャッシュ制御の柔軟化」と「負荷対策」は、いずれもこの前提の上に成り立っているポイントです。

また昨今は、サプライチェーンアタックの勢いが増しており、axiosのマルウェア混入も話題になりました。実際に話題になったときも、依存パッケージを絞っておいたことの安心感は大きかったです。

キャッシュ制御の柔軟化

アプリケーション側ではAPIリクエストのたびにトークンが必要になるので、トークン取得用のメソッドはかなり頻繁に呼ばれます。そのたびに認証基盤へ取りに行くのは負荷的にも勿体ないですし、JWTは exp の範囲内なら使い回して問題ありません。そこでトークンをオンメモリにキャッシュしています。

トークンキャッシュの基本動作は、JWTの exp を見て期限の数分前に更新するというものです。これに加えて、デフォルトより短い寿命を指定したいケース向けに、トークン取得時に渡せる cacheTTL オプションを用意しました。直近の取得から指定された時間を超えていればキャッシュを破棄して取り直す、というシンプルな仕様です。

auth0-reactにも getAccessTokenSilently({ cacheMode }) という似た仕組みがあります。ただ、'on' / 'off' / 'cache-only' の3モードに限定されていて、キャッシュの寿命そのものは内部で固定されています。実際の運用では、可能な限り新鮮なトークンを扱いたいといったニーズが出てきていたため、expに寄らない寿命を指定できる形にしました。

負荷対策

複数のコンポーネントから、トークン取得のような同じ操作がほぼ同時に呼ばれることもあります。たとえば1つの画面で複数のコンポーネントが並んでいて、それぞれ非同期にデータを取得すると想定します。このとき、同じトークン取得処理が並列で走る可能性があります。

これを放置すると、バックエンドへのリクエスト総数が増加してしまいます。そこで、AuthClient の中にMapを持たせ、in-flightなリクエストを共有するsingle-flightの仕組みを入れました。Goのgolang.org/x/sync/singleflightを参考にしています。

async function singleFlight<T>(
  group: Map<string, Promise<T>>,
  key: string,
  factory: () => Promise<T>,
): Promise<T> {
  const existing = group.get(key)
  if (existing != null) {
    return existing
  }
  const p = factory()
  group.set(key, p)
  return p.finally(() => group.delete(key))
}

factory には、重複排除したい実際の通信処理を返す関数を渡します。たとえばトークン取得であれば、AuthClient 内で次のように呼び出すイメージです。

const token = await singleFlight(this.inflight, 'getToken', () => this.fetchToken())

keyはAPIメソッド名にしており、エンドポイント単位で独立して重複排除が効くようにしています。なお、AuthClientから呼ぶ通信処理はいずれもscopeのようなパラメータを取らない設計のため、同じkeyの呼び出しは常に同種のレスポンスを期待しています。そのため、意図しない合流が起きることはありません。

また finally で完了時にエントリを削除しているため、成功・失敗にかかわらず次の呼び出しは新しいflightとして走り、失敗したPromiseが残り続けてリトライを阻むようなことは起きません。

移行直後はアプリケーション側の呼び出しパターンを完全には把握しきれないので、ライブラリ側で重複排除を担保しておくほうが安全だろう、という考えでこの実装を採りました。

実際にトークンを高頻度で取得しようとするアプリケーションがありましたが、この仕組みが機能して急激な負荷上昇を防ぐことができました。

テスト

single-flightなどの個々のロジックを担保する単体テストと、未認証時のログイン画面へのリダイレクトといったフローをテストするE2Eテストを実装しながら開発を進めました。

後者のE2Eテストは、Playwrightのテストケースを書いていきました。

また、ライブラリの挙動をローカルで確かめながら進めるため、簡易的なReactアプリケーションを構築しました。当初はローカルでの動作確認用でしたが、後に開発環境へもデプロイしています。様々な依存や実装を抱えている本番のプロダクトと比較して、ピュアなReactアプリケーションが動く状態をチームメンバーやQAエンジニアと共有できたので、バグや問題があった際の切り分けに役立ったと思います。

アプリケーション側の変更作業

開発の方針でも触れたように、新ライブラリはauth0-react / auth0-spa-jsのAPIや挙動を可能な範囲で踏襲しています。そのためアプリケーション側の移行作業は概ね機械的なもので、@auth0/auth0-reactを新ライブラリに差し替え、以下のような対応表に従ってメソッドや変数名を書き換えれば、コード上のマイグレーションは完了する形にしました。

auth0-react 新ライブラリ
Auth0Provider AuthProvider
useAuth0() useAuth()
logout() logout()

移行にあたっては、サンプル実装を含むマイグレーションドキュメントを展開しました。 そのドキュメントをAI Agentに読み込ませることでマイグレーションを完了したというチームもあり、既存SDKのAPI仕様を踏襲した効果が大きかったと感じています。

終わりに

本記事では、認証基盤の内製化で行ったフロントエンド領域の開発についてご紹介しました。

開発ライブラリの役割はシンプルながら、依存するパッケージを極力排除し、自前でAPIリクエスト処理を書いていく過程で細かな工夫を入れることができ、面白い開発でした。

紹介した内容は、アプリケーションプラットフォームデスクという基盤作りを主な業務とする部署で進めたものでした。基盤チームではありますが、フロントエンド領域も日常的に開発をする少し面白いチームかと思います。

enechainでは、一緒に事業を拡大していける仲間を募集中です。ご興味がある方は以下のリンクをご確認ください。