当サイトは、一部記事に広告を含みます

Auth.js(NextAuth.js)とFirebase Authのセッション切れタイミングを揃える

Next.js + Firebase Authentication + Auth.js(NextAuth.js) を組み合わせて認証・セッション管理をしていたところ、
「画面遷移やNextAuthのセッション自体は有効」なのに、「Firebaseからデータ取得」が失敗することがありました。

Auth.js(NextAuth.js)とFirebase Authのセッション切れタイミングのずれ(Firebase AuthのIDトークンの期限切れ)が原因だったようなので、その際の対応についてまとめます。

原因:Auth.js(NextAuth.js)とFirebase Authのセッション切れタイミングのずれ

FirebaseのJWT(idToken)とAuth.jsのJWTについてまとめた表です。

FirebaseAuth.js
有効期限1時間30日(デフォルト)
用途Firebase API呼び出し時の認証アプリ内のセッション管理
署名Google秘密鍵NEXTAUTH_SECRET
内容uid, email, firebase情報などuid, email, name, カスタムデータなど

こちらの表の通り、2つのJWTは有効期限が異なります。この「切れるタイミングの差」が原因で不具合が発生。

つまり、ログインから1時間経った後、「Firebaseのトークンは切れているのにAuth.jsのセッションは有効のまま」という現象が起きていたもよう。

この2つのJWTがどう認証に関わっているかについては下記参照。

あわせて読みたい

Auth.js(NextAuth.js)とFirebase Authのセッション切れタイミングを揃える

Firebase Authのトークン自動更新について

👉クライアントサイド(ブラウザ)では自動更新される

Firebase SDKでは自動的にトークンの更新が行われるため、開発者が手動でリフレッシュする必要はありません。
ブラウザで.getIdToken(true)を呼び出すと、自動的にトークンがリフレッシュされます。

// 自動リフレッシュ
firebase.auth().currentUser.getIdToken(true) // forceRefreshがtrue

👉サーバーサイドでは自動更新されない

長時間実行されるクライアントセッションでは、サーバーサイドのトークンが期限切れになる可能性があり、通常約1時間後にユーザーがタブを再度開いた場合にページの更新が必要。

つまり、今回のようにFirebase Authentication + Auth.js(NextAuth.js) を組み合わせて認証・セッション管理をしている場合は、トークンの自動更新を実装する必要があるらしい。

解決策:リフレッシュトークンを使ってトークンを自動更新する

Firebaseのリフレッシュトークンを使うと、新しいトークンを取得することができます。

ユーザーがログインするたびに、ユーザー認証情報が Firebase Authentication バックエンドに送信され、Firebase ID トークン(JWT)および更新トークンと交換されます。Firebase ID トークンの有効期間は短く、1 時間で期限切れとなります。新しい ID トークンは、更新トークンを使用して取得できます。 更新トークンは、次のいずれかが発生した場合にのみ有効期限が切れます。

https://firebase.google.com/docs/auth/admin/manage-sessions?hl=ja

ということで、トークンの有効期限をチェックし、期限切れの場合はリフレッシュトークンを使って新しいトークンを取得する処理を実装します。

FIREBASE_TOKEN_API_KEYを取得

Google Cloud コンソール の「API とサービス」>「認証情報」にアクセス。
下記より取得した値を、環境変数FIREBASE_TOKEN_API_KEYに設定します。

続いてコードを書いていきます。

※すでにログイン機能は実装してあるものとし、必要な箇所だけ抜粋して載せます。
ログイン機能の実装は下記のサンプルコードを参照。

あわせて読みたい

Auth.js(NextAuth.js)とFirebase Authのセッション切れタイミングを揃える

フロント側(ログイン処理)

// src/components/LoginForm.tsx
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
    const userCredential =  await signInWithEmailAndPassword(auth, email, password);
           
    const idToken = await userCredential.user.getIdToken();
    const refreshToken = userCredential.user.refreshToken; // リフレッシュトークンを取得
    await signIn('credentials', {
        idToken,
        refreshToken, // authjsにリフレッシュトークンを渡す
        redirect: true,
        callbackUrl: '/user/home',
     });
}

サーバー側

有効期限のチェック、リフレッシュトークンを使って新しいトークンを取得する部分を実装します。

// ‎src/app/api/auth/[...nextauth]/options.ts

// 定数の定義
const TOKEN_REFRESH_BUFFER = 5 * 60; // 5分前にリフレッシュ
const SESSION_MAX_AGE = 30 * 24 * 60 * 60; // 30日

/**
 * Firebase Refresh Tokenを使用して新しいID Tokenを取得
 */
const fetchNewIdToken = async (refreshToken: string) => {
  const res = await fetch(
    `https://securetoken.googleapis.com/v1/token?key=${process.env.FIREBASE_TOKEN_API_KEY}`,
    {
      method: 'POST',
      body: JSON.stringify({
        grant_type: 'refresh_token',
        refreshToken,
      }),
    },
  )
  const { id_token } = await res.json();
  return id_token
}

/**
 * Firebase ID Tokenを検証してユーザー情報を取得
 */
const verifyFirebaseToken = async (idToken: string): Promise<User | null> => {
  try {
    const decoded = await adminAuth.verifyIdToken(idToken);    
    return {
      id: decoded.uid,
      uid: decoded.uid,
      email: decoded.email || '',
      tokenExpiryTime: decoded.exp || 0,
      idToken,
    };
  } catch (error) {
    console.error('Firebase ID token verification failed:', error);
    return null;
  }
};

export const authConfigs: NextAuthOptions = {
    debug: process.env.NEXTAUTH_DEBUG === 'true',
    session: {
        strategy: "jwt",
        maxAge: SESSION_MAX_AGE, // 30日間
    },
    jwt: {
        maxAge: SESSION_MAX_AGE, // 30日間
    },
    providers: [
        CredentialsProvider({ 
                name: "Firebase Auth",
                credentials: {
                    idToken: { label: "ID Token", type: "text" },
                    refreshToken: { label: "Refresh Token", type: "text" },
                },

                async authorize(credentials) {
                    if (!credentials?.idToken) {
                        console.error('No ID token provided');
                        return null;
                    }

                    const user = await verifyFirebaseToken(credentials.idToken);
                    if (!user) return null;

                    // Refresh tokenがあれば追加
                    if (credentials.refreshToken) {
                        user.refreshToken = credentials.refreshToken;
                    }

                    return user;
                }
            }
        ),
    ],
    callbacks: {
        async jwt({ token, user }) {
        // 初回ログイン時:Firebase情報をNextAuth JWTに保存
        if (user) {
            token.uid = user.id;
            token.email = user.email ?? "";
            token.tokenExpiryTime = user.tokenExpiryTime;
            token.refreshToken = user.refreshToken;
            token.idToken = user.idToken
        }

        // トークンの有効期限チェック
        const currentTime = Math.floor(Date.now() / 1000)
        const tokenExpiryTime = token.tokenExpiryTime as number
        const isExpired = currentTime > tokenExpiryTime - TOKEN_REFRESH_BUFFER; // 5分前にリフレッシュ

        // 有効期限が迫っていたらリフレッシュ
        if (isExpired) {
            try {
                const newIdToken = await fetchNewIdToken(token.refreshToken as string);
                // 新しいトークンを検証して情報を更新
                const verifiedUser = await verifyFirebaseToken(newIdToken);
                if (verifiedUser) {
                    token.idToken = newIdToken;
                    token.tokenExpiryTime = verifiedUser.tokenExpiryTime;
                }
            } catch (error) {
                console.error('Error refreshing token:', error)
            }
        }
        return token;
        },

        async session({ session, token }) {
            // Authjs JWTからセッション情報を構築
            if (token.uid && token.email) {
                session.user.uid = token.uid;
                session.user.email = token.email;
                session.user.idToken = token.idToken
            }
        return session;
        },
    },
}

参考

ありがとうございます!!!

---

記事が気に入ったら、ぜひ応援いただけると更新がんばれます☕️

Buy Me A Coffee