JWTExpired : FirebaseAuthentication + HASURA + Next.jsでJTWトークンの期限切れが起きた時の対処法

バックエンドをHASURA、ユーザー管理とFirebaseAuthenticationで運用している場合、HASURAの認証をJWTトークンで行います。

FirebaseではIDトークンの期限が1時間しかないため、最初にトークンを取得してから1時間経過すると、同じトークンが使えなくなってしまいます。

Firebase ID トークンの有効期間は短く、1 時間で期限切れとなります。新しい ID トークンは、更新トークンを使用して取得できます。
(Firebaseのドキュメント : ユーザー セッションの管理 より)

今回このエラーに遭遇し、自分なりに対処したのでやったことをメモ代わりに共有します。
(ちなみにアプリケーションはNext.js、APIはGraphQLです)

結論 : 期限切れになる時間(タイムスタンプ)をcookieに入れておく

結果的にやったことだけいうと、トークンの期限が切れる時間 = トークン取得時から1時間後を示すタイムスタンプをcookieに入れておくことにしました。

その上で認証が必要なバックエンド処理をするときに毎回該当のcookieを参照し、時間が過ぎていたらトークンを取得し直す、という感じ。

とりあえずこれでうまくいっています。
他にもいくらでもやり方があるとおもうので、より簡単・安全な方法があれば教えていただきたいですmm

基本的な実装

もともとやっていた実装は普通です。
ログインしたときにトークンを取得してcookieにストアするというやり方ですね。

以下はログインやサインアップのときに呼び出すhookの一部です。

  useEffect(() => {
    const unSubUser = firebase.auth().onAuthStateChanged(async (user) => {
      const alreadyToken = cookie.get('token')
      if (alreadyToken) {
        return
      }

      if (user) {
        const token = await user.getIdToken(true)
        const idTokenResult = await user.getIdTokenResult()
        const hasuraClaims = idTokenResult.claims[HASURA_TOKEN_KEY]

........... //以下省略

またHASURA側では、パーミッションでカスタムチェックを入れておいたカラムは、トークン情報が合致しないと参照できないようにしてあります。

考え方としては、認証サーバーがJWTトークンを返し、それをGraphQLエンジンがデコードして検証し、リクエストに関するメタデータ(x-hasura-*値)を認可して取得するというものです。
(HASURAのドキュメント : Authentication using JWTDeepLで翻訳)

一応この設定であれば、ログインしてしばらく(具体的には1時間)はJWTトークンによる認証が動作します。

TokenExpiredのエラー

そんな感じで開発を進めているとエラーが。

//エラーレスポンスの一部
{
  "errors": [{
        "extensions": {
        "path": "$",
        "code": "invalid-jwt"
      },
      "message": "Could not verify JWT: JWTExpired"
  }]
}

とくに認証周りのコードを変更していないのに・・と思ったらトークンが期限切れとのこと。
ここで初めてFirebaseのIDトークンの有効期限が1時間であることを知りました。

トークンの期限を現在日時と比較し、過ぎていたら取得し直す

ログイン時のコードに、コメントアウト以下の3行を追記。

useEffect(() => {
    const unSubUser = firebase.auth().onAuthStateChanged(async (user) => {
      const alreadyToken = cookie.get('token')
      if (alreadyToken) {
        return
      }

      if (user) {
        const token = await user.getIdToken(true)
        const idTokenResult = await user.getIdTokenResult()
        const hasuraClaims = idTokenResult.claims[HASURA_TOKEN_KEY]

        //現在日時にプラス1時間してcookieにセット
        const now: number = Date.now()
        const expireTimestamp: number = now + 1000 * 60 * 60
        await cookie.set('token_expire', expireTimestamp, { path: '/' })

これでトークン取得から + 1時間を保持することができます。

さらに、データのupdateやdelete、一部のselectなどユーザー認証が必要な操作をするhookでは、以下のようにuseEffectさせます

useEffect(() => {
    const now = new Date()
    const expireTimestamp = cookie.get('token_expire')

    if (now > expireTimestamp) {
      firebase.auth().onAuthStateChanged(async (user) => {
        if (user) {
          const token = await user.getIdToken(true)
          const idTokenResult = await user.getIdTokenResult()
          const hasuraClaims = await idTokenResult.claims[HASURA_TOKEN_KEY]
          await cookie.set('token', token, { path: '/' })
        }
      })

      cookie.set('token_expire', Date.now() + 1000 * 60 * 60, { path: '/' })
    }
  }, [cookie.get('token_expire')])

これで、1時間経過していたら自動的にIDトークンを更新するようになりました。

hookを分けて書いているとそれぞれに対してこの処理を追加しないといけないのでスマートなやり方とはいえないですが・・

試してうまく行かなかった方法

最初に試みたのはこんなこと。
クエリ実行時にエラーが返却されるので、「その内容がJWTExpiredであればIDトークン取得を自動実行する」という仕組みにしようとしました。

ReactQueryでコールバックとしてonErrorが用意されていたので、こちらを利用してエラーの内容をキャッチしてconsole.logしてみるとエラーメッセージが表示されます。
参考 : useMutation – React Query

しかしエラーのテキストからindexOfするなど、それ以上の処理がなぜか出来ずでした。残念。

認証周りは安全第一。ドキュメントを読み込む

FirebaseやAuth0を使ってユーザー管理がしやすくなっていますが、他サービスとの連携が前提の場合はAPIの仕様を詳細に把握しておく必要があります。

コード管理の手間が省けた分仕様理解が大変になっちゃう気もしますが、一度わかれば後は楽なので使い始めはここらへん頑張りたいですね。

今回はこれまで。ではまた!