Firebase Authentication x NextAuth.jsでリフレッシュトークンの実装

投稿日
Firebase Authentication x NextAuth.jsでリフレッシュトークンの実装

Firebase Authentication と NextAuth.js を組み合わせて認証の実装を以前記載しました


↑ で基本となるアクセストークンは取得できるようになりました。

が、リフレッシュトークンを使った更新作業をしないため、アクセストークンの有効期限が切れると再度ログインを求められます。



Firebase Authenticationのアクセストークンは1時間で期限が切れるため、正直上記の状態だとサービスとしては使いものにならないレベルになってしまいます…🥲

厳密にはFirebase AuthenticationのIDトークンをアクセストークンとして利用します。

IDトークンの有効期限は1時間です

ユーザー セッションの管理  |  Firebase Authentication




この記事では

Firebase Authentication と NextAuth.js を組み合わせつつ、リフレッシュトークンを使いアクセストークンを更新する方法について記載します。

トークンのリフレッシュ処理順

検証した環境

1 next-auth 5.0.0-beta.20
2 next 14.1.3
3 firebase-admin 13.0.1

現状の確認

以前の記事のコードを元に記載していくので、簡略化しつつコードを記載します

コードの確認

ログインのUIコンポーネント

18行目でNextAuthのログイン処理のServer Actionsを呼び出します

src/app/login/page.tsx
'use client'
 
import { useFormState, useFormStatus } from 'react-dom'
import { login } from '@/actions/login'
 
function SubmitButton() {
  const { pending } = useFormStatus()
 
  return (
    <button type="submit" style={pending ? { opacity: '30%' } : {}} disabled={pending}>
      {pending ? 'ログイン中...' : 'ログイン'}
    </button>
  )
}
 
export default function LoginPage() {
  // ログイン処理をformタグのactionsから呼び出せるよう定義
  const [state, formAction] = useFormState(login, { message: null })
 
  return (
    <div>
      <h1>ログイン</h1>
      <form action={formAction}>
        <input type="email" name="email" placeholder="メールアドレス" />
        <input type="password" name="password" placeholder="パスワード" />
        <SubmitButton />
      </form>
 
      {state.message && <p>{state.message}</p>}
    </div>
  )
}


ログインを実行するServer Actions

Server ActionsではNextAuthの signIn を呼び出します

src/actions/login.ts
'use server'
 
import { signIn } from '@/auth'
import { redirect } from 'next/navigation'
 
type State = {
  message: string | null
}
 
export async function login(previousState: State, formData: FormData): Promise<State> {
  const email = formData.get('email')
  const password = formData.get('password')
 
  try {
    // NextAuth のログインを実行する
    await signIn('credentials', { email, password, redirect: false })
  } catch (error) {
    console.error(error)
    return { message: 'ログインに失敗しました' }
  }
 
  redirect('/dashboard')
}


NextAuth.jsの設定

認証周りを管理するNextAuth.jsの設定は src/auth.ts に記載しています

  • Credentials > authorize ・・ログイン処理
  • callbacks > jwt ・・cookieに認証情報を保存する際に使用
  • callbacks > session ・・Server Actions・Route Handler等で利用する session 変数で認証情報を使えるようにする
src/auth.ts
import NextAuth, { NextAuthConfig, User } from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
 
export const { signIn, auth } = NextAuth({
  providers: [
    Credentials({
      authorize: async ({ email, password }) => {
        // ログイン用のRoute Handlerを呼び出す
        const res = await fetch(`${process.env.NEXT_API_URL}/login`, {
          method: 'POST',
          body: JSON.stringify({ email, password }),
        })
 
        if (!res.ok) return null
 
        // 取得したアクセストークンを返し保存に繋げる
        const json = await res.json()
        return {
          accessToken: json.accessToken,
        }
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      // userが存在する(ログイン後)場合、accessTokenを追加
      if (user) {
        token.accessToken = user.accessToken
      }
      // cookieに保存する
      return token
    },
    async session({ session, token }) {
      // token(cookieに保存している値のこと)内のaccessTokenを
      // Server ActionsやRoute Handlerで利用する session 変数に追加し使えるようにする
      session.user.accessToken = token.accessToken
      return session
    },
  },
} satisfies NextAuthConfig)


ログイン用Route Handler

NextAuthのログイン処理で呼び出されるRoute Handlerは

email・passwordでfirebase Authenticationにログインしアクセストークンを返します

app/api/login/route.ts
import { NextRequest, NextResponse } from 'next/server'
 
export async function POST(request: NextRequest) {
  const { email, password } = await request.json()
 
  if (!email || !password) {
    return NextResponse.json('parameter is missing.', { status: 400 })
  }
 
  // Firebase Auth REST APIを使ってメールアドレス/パスワードでログインするための情報の定義
  const endpoint = `https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${process.env.FIREBASE_API_KEY}`
  const headers = { 'Content-Type': 'application/json' }
  const body = JSON.stringify({ email, password, returnSecureToken: 'true' })
 
  const response = await fetch(endpoint, { method: 'post', headers, body })
  const json = await response.json()
 
  return NextResponse.json({ accessToken: json.idToken })
}

実際の動作

これでログインするとアクセストークンが保存され



認証が必要なエンドポイントにアクセスすると認証状態の確認が出来ます

トークンをリフレッシュする

認証の流れに

  • リフレッシュトークンを取得・保存
  • アクセストークンの有効期限が切れた際にリフレッシュトークンを使ってアクセストークンを更新

この2点を追加することでアクセストークンを永続化していきます!


リフレッシュトークンの取得・保存

googleのAPIを使ってログインする際、実はリフレッシュトークンの情報も含まれています

googleapisでログインを行った際のレスポンス例
{
  "localId": "ZY1rJK0eYLg...",
  "email": "[user@example.com]",
  "displayName": "",
  "idToken": "[ID_TOKEN]",
  "registered": true,
  "refreshToken": "[REFRESH_TOKEN]",
  "expiresIn": "3600"
}

REST API の使用  |  Identity Platform Documentation  |  Google Cloud より引用



まず

  • アクセストークンの有効期限
  • リフレッシュトークンの値

を取得し、保存する処理を追加します



リフレッシュトークンの取得

まずログイン時にアクセストークン以外に必要な値を呼び出し元にも返すようにします

app/api/login/route.ts
import { NextRequest, NextResponse } from 'next/server'
 
export async function POST(request: NextRequest) {
  const { email, password } = await request.json()
 
  if (!email || !password) {
    return NextResponse.json('parameter is missing.', { status: 400 })
  }
 
  // Firebase Auth REST APIを使ってメールアドレス/パスワードでログインするための情報の定義
  const endpoint = `https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${process.env.FIREBASE_API_KEY}`
  const headers = { 'Content-Type': 'application/json' }
  const body = JSON.stringify({ email, password, returnSecureToken: 'true' })
 
  const response = await fetch(endpoint, { method: 'post', headers, body })
  const json = await response.json()
 
  const now = new Date()
 
  return NextResponse.json({
    // アクセストークン。引き続きidTokenを用いる
    accessToken: json.idToken,
    // アクセストークンの有効期限。expiresInは秒数で返ってくる
    accessTokenExpiresIn: new Date(now.getTime() + Number.parseInt(json.expiresIn) * 1000),
    // リフレッシュトークン
    refreshToken: json.refreshToken,
    // リフレッシュトークンの有効期限
    // firebase authenticationのリフレッシュトークンは明確な有効期限がないため一般的な30日とする
    refreshTokenExpiresIn: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000),
  })
}

重要な点として

Firebase Authenticationのリフレッシュトークンは有効期限がないため、

リフレッシュトークンの有効期限として一般的な30日として返します


リフレッシュトークンの保存

NextAuth.jsの型情報を更新。

NextAuth内でリフレッシュトークン周りの値を扱えるようにし、

type.d.ts
import 'next-auth'
import 'next-auth/jwt'
 
declare module 'next-auth' {
  interface User {
    accessToken: string
    accessTokenExpiresIn: string
    refreshToken: string
    refreshTokenExpiresIn: string
  }
}
 
declare module 'next-auth/jwt' {
  interface JWT {
    accessToken: string
    accessTokenExpiresIn: string
    refreshToken: string
    refreshTokenExpiresIn: string
  }
}


ログイン時に保存します

src/auth.ts
export const { signIn, auth } = NextAuth({
  providers: [
    Credentials({
      authorize: async ({ email, password }) => {
        // ログイン用のRoute Handlerを呼び出す
        const res = await fetch(`${process.env.NEXT_API_URL}/login`, {
          method: 'POST',
          body: JSON.stringify({ email, password }),
        })
 
        if (!res.ok) return null
 
        const json = await res.json()
        // ログイン時に取得したリフレッシュトークン周りの値も返すようにする
        return {
          accessToken: json.accessToken,
          accessTokenExpiresIn: json.accessTokenExpiresIn,
          refreshToken: json.refreshToken,
          refreshTokenExpiresIn: json.refreshTokenExpiresIn,
        }
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      // userが存在する(ログイン後)場合、認証情報を追加
      if (user) {
        token.accessToken = user.accessToken
        token.accessTokenExpiresIn = user.accessTokenExpiresIn
        token.refreshToken = user.refreshToken
        token.refreshTokenExpiresIn = user.refreshTokenExpiresIn
      }
      // cookieに保存
      return token
    },
    async session({ session, token }) {
      session.user.accessToken = token.accessToken
      // sessionで確認するためにaccessToken以外も追加
      session.user.accessTokenExpiresIn = token.accessTokenExpiresIn
      session.user.refreshToken = token.refreshToken
      session.user.refreshTokenExpiresIn = token.refreshTokenExpiresIn
      return session
    },
  },
} satisfies NextAuthConfig)


これで確認用のエンドポイントにアクセスしてみると

app/api/session-confirm/route.ts
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
 
export async function GET() {
  // NextAuthの内容を取得
  const session = await auth()
  // ブラウザ上に保存されている内容を表示
  return NextResponse.json({ session })
}
リフレッシュトークンが保存されていることを確認

refreshToken周りの値が保存されていることを確認できます!


アクセストークンの更新

アクセストークンの有効期限が切れた場合、リフレッシュトークンを使ってアクセストークンを更新します。

実装は大きく2つです

  • トークンのリフレッシュを行えるようにする
  • アクセストークンの有効期限が切れた際に上記リフレッシュ処理を実施

トークンのリフレッシュ処理

リフレッシュトークンを元にアクセストークンを更新するためのエンドポイントを作ります

※ エラーハンドリングはせず最小限の構成になってます
app/api/refresh-token/route.ts
import { NextRequest, NextResponse } from 'next/server'
 
export async function POST(request: NextRequest) {
  const { refreshToken } = await request.json()
 
  if (!refreshToken) {
    return NextResponse.json('parameter is missing.', { status: 400 })
  }
 
  const endpoint = `https://securetoken.googleapis.com/v1/token?key=${process.env.FIREBASE_API_KEY}`
  const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }
 
  const parameters = new URLSearchParams()
  parameters.append('grant_type', 'refresh_token')
  parameters.append('refresh_token', refreshToken)
 
  const response = await fetch(endpoint, { method: 'post', headers, body: parameters })
  const json = await response.json()
 
  const now = new Date()
 
  return NextResponse.json({
    accessToken: json.id_token,
    accessTokenExpiresIn: new Date(now.getTime() + Number.parseInt(json.expires_in) * 1000),
    refreshToken: json.refresh_token,
    refreshTokenExpiresIn: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000),
  })
}

注意点は以下です

  • エンドポイントのURLがログイン・新規登録等で使う identitytoolkit.googleapis.com ではなく securetoken.googleapis.com になっている
  • リクエストのbodyは application/x-www-form-urlencoded で送信
    • そのため URLSearchParams を使ってリクエストのbodyを作成
  • レスポンスがログイン時の idToken ではなく id_token のようにスネークケースになっている

特に最後のキャメルケースからスネークケースへの変更は気付きづらいため注意が必要です


参考: REST API の使用  |  Identity Platform Documentation  |  Google Cloud




サンプルのレスポンスは以下のようになります

{
  access_token: 'eyJhbGciOiJSUzI1NiIsImtpZCI6ImEwODA2N2Q4M2YwY2Y5YzcxNjQyNjUwYzUyMWQ0ZWZhNWI2YTNlMDkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vbmV4dC1hdXRoLXNhbXBsZS0zY2E0YiIsImF1ZCI6Im5leHQtYXV0aC1zYW1wbGUtM2NhNGIiLCJhdXRoX3RpbWUiOjE3NDIyMjM5NzcsInVzZXJfaWQiOiJjS251TkJEYmFEVGZCRlVwVG51cWZwWVkwbjcyIiwic3ViIjoiY0tudU5CRGJhRFRmQkZVcFRudXFmcFlZMG43MiIsImlhdCI6MTc0MjMxMTE0NSwiZXhwIjoxNzQyMzE0NzQ1LCJlbWFpbCI6InRlc3RAdGVzdC5jb20iLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsImZpcmViYXNlIjp7ImlkZW50aXRpZXMiOnsiZW1haWwiOlsidGVzdEB0ZXN0LmNvbSJdfSwic2lnbl9pbl9wcm92aWRlciI6InBhc3N3b3JkIn19.fFv6bOabc_BnMijLqywqVcfq7TYjmgvFR757SFCq9_eR9SCXNzZB6g8MzU0Kbf3HbBh9wy52BLbsslyW7O9l2pCF4mvaEjNU7QfHWeF2SjlYms4UYysP1UAx9vWEwI8w9J6qL43L2Y2EjMYJWxFxqHLrX_uuF3l75R58dT9EasVNeA7nyBUOoGKzZzDuOQbxY5Df-rfNa75AoLXrM1qZDFr5utQeaGURuB_va7EKvMpu86x6ztMgeYS8aYwCR6Ya15C5KiJPBr-6HnWfQMr6bOe183HGo_TZssM1sWqZyfiBAUxxx3zoPaIFnO_yq5RM8X2bPk1m6CqHLqidAgXg',
  expires_in: '3600',
  token_type: 'Bearer',
  refresh_token: 'AMf-vBzAVJYCE8w-rKPXLnEQhvl-IfvWvaTmjlUwhF7tVuoP244MBOxR3CjGqAPvGtRH3pFZCJNau98EJWi8KCiOGxhodvRygV3r9cr0HGrfe0PrWronFLRdo2KwckxHC03lwO2MHPzHLxG4WA9LwW3uNCXnSSq0TRCWSlzWsqG-d6lwWLc6XT0VJdm4l2uVHV_Y4vur4sxxxxx1jNyn1N5iNcL-z7o3g',
  id_token: 'eyJhbGciOiJSUzI1NiIsImtpZCI6ImEwODA2N2Q4M2YwY2Y5YzcxNjQyNjUwYzUyMWQ0ZWZhNWI2YTNlMDkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vbmV4dC1hdXRoLXNhbXBsZS0zY2E0YiIsImF1ZCI6Im5leHQtYXV0aC1zYW1wbGUtM2NhNGIiLCJhdXRoX3RpbWUiOjE3NDIyMjM5NzcsInVzZXJfaWQiOiJjS251TkJEYmFEVGZCRlVwVG51cWZwWVkwbjcyIiwic3ViIjoiY0tudU5CRGJhRFRmQkZVcFRudXFmcFlZMG43MiIsImlhdCI6MTc0MjMxMTE0NSwiZXhwIjoxNzQyMzE0NzQ1LCJlbWFpbCI6InRlc3RAdGVzdC5jb20iLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsImZpcmViYXNlIjp7ImlkZW50aXRpZXMiOnsiZW1haWwiOlsidGVzdEB0ZXN0LmNvbSJdfSwic2lnbl9pbl9wcm92aWRlciI6InBhc3N3b3JkIn19.fFv6bOabc_BnMijLqywqVcfq7TYjmgvFR757SFCq9_eR9SCXNzZB6g8MzU0Kbf3HbBh9wy52BLbsslyW7O9l2pCF4mvaEjNU7QfHWeF2SjlYms4UYysP1UAx9vWEwI8w9J6qL43L2Y2EjMYJWxFxqHLrX_uuF3l75R58dT9EasVNeA7nyBUOoGKzZzDuOQbxY5Df-rfNa75AoLXrM1qZDFr5utQeaGURuB_va7EKvMpu86x6ztMgeYS8aYwCR6Ya15C5KiJPBr-6HnWfQMr6bOe183HGo_TZssM1sWqZyfiBAUxxx3zoPaIFnO_yq5RM8X2bPk1m6CqHLqidAgXg',
  user_id: 'cKnuNBDbaDTfBFUpTnuqfpxx0n72',
  project_id: '534803655629'
}


トークンの有効期限に応じた処理

最後にアクセストークンを読み込む時に有効期限を確認し、期限が切れていた場合はリフレッシュ処理を行います。


セッション情報の保存・更新を行う callbacks > jwt で実装します

src/auth.ts
export const { signIn, auth } = NextAuth({
  providers: [
    // ・・省略・・
  ],
  callbacks: {
    async jwt({ token, user }) {
      // ログイン後などのuserが存在する場合はtokenに追加する
      if (user) {
        token.accessToken = user.accessToken
        token.accessTokenExpiresIn = user.accessTokenExpiresIn
        token.refreshToken = user.refreshToken
        token.refreshTokenExpiresIn = user.refreshTokenExpiresIn
 
        return token
      }
 
      const now = new Date()
      const accessTokenExpiresIn = new Date(token.accessTokenExpiresIn)
      const refreshTokenExpiresIn = new Date(token.refreshTokenExpiresIn)
      const refreshToken = token.refreshToken
 
      // リフレッシュトークンの有効期限が切れている場合は問答無用でログアウト
      if (refreshTokenExpiresIn.getTime() < now.getTime()) {
        // console.log('リフレッシュトークンの期限が切れています')
        return null
      }
      // アクセストークンの有効期限が切れている場合はリフレッシュトークンを使って更新
      if (accessTokenExpiresIn.getTime() < now.getTime()) {
        console.log('アクセストークンの期限が切れています')
 
        // リフレッシュトークンが存在しない場合は更新できないためログアウト
        if (!refreshToken) return null
 
        console.log('トークンを更新します')
        // リフレッシュトークンを使ってアクセストークンを更新するためのエンドポイントを呼び出す
        const res = await fetch(`${process.env.NEXT_API_URL}/refresh-token`, {
          method: 'POST',
          body: JSON.stringify({ refreshToken }),
        })
 
        if (!res.ok) return null
 
        // 更新したアクセストークンを保存出来るようtokenに追加
        const json = await res.json()
        console.log('更新したアクセストークン', json)
        
        token.accessToken = json.accessToken
        token.accessTokenExpiresIn = json.accessTokenExpiresIn
        token.refreshToken = json.refreshToken
        token.refreshTokenExpiresIn = json.refreshTokenExpiresIn
      }
      return token
    },
    async session({ session, token }) {
      // ・・省略・・
    },
  },
} satisfies NextAuthConfig)


アクセストークンの有効期限が切れている場合にアクセスすると

アクセストークンがリフレッシュされる

アクセストークンが更新されデータが取得出来ました!




分かりづらいですが流れとしては

  • アクセストークンの有効期限が切れている場合はリフレッシュトークンを使ってアクセストークンを更新
    • 「アクセストークンの期限が切れています」がログに表示
    • 「トークンを更新します」がログに表示
  • 更新に成功した場合はアクセストークンを保存出来るようtokenに追加
    • 「更新したアクセストークン」がログに表示
  • returnしてcookieに保存
  • (cookieに保存した内容を使いAPIを叩く)

となります




リフレッシュ処理が問題なく行えているかを検証する場合は

// アクセストークンの有効期限より後の時間を指定
const now = new Date("2025-03-19T00:00:00.000Z")

のように時間を先送りすることで検証可能です

プロフィール画像
Yuki Takara
都内でフリーランスのエンジニアをやってます。フロントとアプリ開発メインに幅広くやってます。