Firebase Authentication x NextAuth.jsで認証の実装

投稿日
/
更新日
Firebase Authentication x NextAuth.jsで認証の実装

Next.jsはv14になってから

App Router・React Server Component(以降 RSC)・Server Actionsが広く浸透・利用されるようになりました。



この記事では

  • 認証基盤・・Firebase Authentication
  • 認証用ライブラリ・・NextAuth.js

として、

RSC や Server Actions内でFirebase Authenticationが払い出したaccessTokenを用いて認証チェックを行う方法を記載します。

検証した環境

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

作っていく構成について

最終的に大きく3つ実装します

ログイン

ログインの構成

データ取得(get)

getの構成

データ送信(post)

postの構成


以下の順番で実装を進めます

  • NextAuth.jsの設定
  • Firebase Authenticationの設定
  • ログイン
  • AccessTokenの保存
  • データ取得(get)
  • データ送信(post)

アクセストークンのリフレッシュ処理は別記事に記載します

NextAuthの初期設定

NextAuth.jsのパッケージを追加します



2024.11.19現在、B版ですが今後標準となっていくであろうv5を使っていきます

$ npm install next-auth@beta

v4 と v5 の更新内容は以下になっています

NextAuth v4とv5の違い

https://authjs.dev/getting-started/migrating-to-v5 より引用

  • メソッドが統一され分かりやすくなった
  • Route Handler・API Route でも利用出来るようになった

というのが大きな違いですね


ページの用意

ログインページ と ログイン後に遷移するダッシュボードページ を用意します

app/login/page.tsx
export default function LoginPage() {
  return (
    <div>
      <h1>ログイン</h1>
      <form>
        <input type="email" name="email" placeholder="メールアドレス" />
        <input type="password" name="password" placeholder="パスワード" />
        <button type="submit">ログイン</button>
      </form>
    </div>
  )
}
app/dashboard/page.tsx
export default function DashboardPage() {
  return (
    <h1>ダッシュボード</h1>
  )
}

ログイン処理の雛形を用意

NextAuthの設定ファイルを用意しログインが実行出来ることを確認します


まずテストとして

  • email・・ test@test.com
  • password・・ password

↑の固定値を受け取ったらログインが出来るようにします

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 }) => {
        // APIにリクエストを送ったように見せかけるために2秒待機
        await new Promise((resolve) => setTimeout(resolve, 2000))
 
        return email === 'test@test.com' && password === 'password'
          ? { name: 'hoge', email: 'test@test.com' }
          : null
      },
    }),
  ],
} satisfies NextAuthConfig)



ログイン用のServer Actionsを用意します


Server Actions 内でNextAuth.jsのログイン処理を実行することで src/auth.ts で定義した authorize 内の処理が呼び出されます

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')
}

signIn メソッドで redirect: false としているのは、 redirect が実行されると throw が投げられるためです。

throwが投げられると signInメソッドすぐ下の catch に入ってしまい、意図しない挙動になってしまいます




ログインページで上記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・useFormState については以下の記事を参考下さい


ログインを実行するとダッシュボードページに遷移し、

NextAuthの認証情報 authjs.session-token が無事保存出来ていることが確認出来ます!

Firebase Authentication の設定

NextAuthを使ったログインの土台が出来たので、

認証基盤となるFirebase Authenticationの設定を行っていきます





Firebaseに新しいプロジェクトを作成していきます

Firebaseで新しいプロジェクトを作成開始
Firebaseに新しいプロジェクトの作成が出来た


メールとパスワード でのログインを行えるようにするため

サイドバーからAuthenticationを選択し「メール/パスワード」を選択し有効にします

メール/パスワードを選択
メール・パスワードの有効にする

Firebase Authenticationでメール/パスワードを行えるようにする準備が出来ました!


ログイン用のエンドポイントの用意

Firebase Auth REST API を使ってログイン用のエンドポイントを用意します。




FirebaseのプロジェクトのAPI key を確認し環境変数に設定します

firebaseのAPIキーを確認する
.env.local
# Firebase上で取得した ウェブAPIキー を設定する
FIREBASE_API_KEY={{ウェブAPIキーを設定}}




ログイン用のエンドポイントを Route Handler を使って用意します。



最低限の挙動を確認したいので

  • エラーハンドリングなし
  • Firebase Auth REST APIで正常に値を取得出来たら、idTokenをaccessTokenに見立ててレスポンスに含める

でいきます

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 })
}


Firebase Authentication 上にアカウントを作っておきます

Firebase Authenticationにテスト用のアカウントを作成


NextAuthのログイン処理部分でRoute Handlerを呼び出します

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
 
        // accessTokenが取得出来ていることを確認
        const json = await res.json()
        console.log(json.accessToken)
 
        // emailは設定しないとデフォルトでunknown型となるので強制的にstring型とする
        return { name: 'hoge', email: email as string }
      },
    }),
  ],
} satisfies NextAuthConfig)

ログイン実行後、Route Handlerで取得したaccessTokenの値を無事出力することが出来ました!

Access Tokenの保存

accessTokenが取得出来たのでNextAuthを用いてブラウザに保存して永続化します




accessTokenはNextAuthでデフォルト定義されていない値のため、設定をしないと保存や利用することが出来ません。


そのため

  • 型の拡張
  • auth.ts で保存・取得処理

の2点の対応が必要です(詳細は以下記事参照下さい)




NextAuthの型を拡張し accessToken という値を保存・利用しても値が存在しないエラーが起きないようにします

type.d.ts
import NextAuth from 'next-auth'
import { JWT } from 'next-auth/jwt'
 
// NextAuthでaccessTokenを使用可能にする
 
declare module 'next-auth' {
  interface User {
    accessToken: string
  }
}
 
declare module 'next-auth/jwt' {
  interface JWT {
    accessToken: string
  }
}


auth.tsを更新し、accessTokenを保存・取得出来るようにします

  • ログイン(authorize)した際にaccessTokenをreturnする
  • callbacks > jwt でcookieに保存
  • callbacks > session でServer Actions・Route HandlerなどでaccessTokenを利用可能にする
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
 
        // accessTokenが取得出来ていることを確認
        const json = await res.json()
 
        return {
          name: 'hoge',
          // emailは設定しないとデフォルトでunknown型となるので強制的にstring型とする
          email: email as string,
          accessToken: json.accessToken,
        }
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      // userが存在する場合、保存するJWTにaccessTokenを追加する
      if (user) {
        token.accessToken = user.accessToken
      }
      return token
    },
    async session({ session, token }) {
      // token(cookieに保存している値のこと)内のaccessTokenを
      // Server ActionsやRoute Handlerで利用する session 変数に追加し使えるようにする
      session.user.accessToken = token.accessToken
      return session
    },
  },
} satisfies NextAuthConfig)
 


これでaccessTokenが永続化されて、Server Actionsなどからも参照できます!



保存されていることを確認するために簡単なRouter Handlerを用意してみます

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 })
}

http://localhost:3000/api/session-confirm にアクセスすると

accessTokenがNextAuth内に保存されていることを確認

accessTokenが表示されます




そして、ブラウザリロードしてもaccessTokenが取得出来ていることが分かります。

これはcookieの authjs.session-token にaccessTokenが追加で保存されているためです。



暗号化されているのでパッと見だと全く分かりませんが、

accessTokenを保存するようにした前後でデータ量がかなり大きくなっていることが分かります。

beforeafter
after
before

Access Tokenを用いてアクセスする

最後です!

認証が必要な処理に対してaccessTokenを付与し問い合わせを行います。



データを取得する場合(RSC)と、データを投げる場合(Server Actions)に分けて記載します

前準備

今回はサーバー側で受け取ったaccessTokenの認可もNext.js上で行うため firebase-admin を導入します

$ pnpm install firebase-admin


Next.jsでfirebase-adminを使えるようにするため、Fibaseのプロジェクトページで秘密鍵を生成

firebase-adminに設定するための秘密鍵を生成

以下のようなjsonがDLされます

{
  "type": "service_account",
  "project_id": "xxxxxxxx",
  "private_key_id": "1234567890",
  "private_key": "-----BEGIN PRIVATE KEY-----\nxxxxxxxxx=\n-----END PRIVATE KEY-----\n",
  "client_email": "firebase-adminsdk-xxxxxxxxxxx.iam.gserviceaccount.com",
  ・・・
  "universe_domain": "googleapis.com"
}


環境変数に3つ値を設定し、firebaseのプロジェクトと連携出来るようにします

.env.local
FIREBASE_ADMIN_PROJECT_ID={{project_id の値}}
FIREBASE_ADMIN_CLIENT_EMAIL={{client_email の値}}
FIREBASE_ADMIN_PRIVATE_KEY="{{private_key の値}}"


firebaseの設定を行うファイルを追加しておきます

src/libs/firebase.ts
import process from 'node:process'
import { initializeApp, getApps, cert, getApp, App } from 'firebase-admin/app'
import { Auth, getAuth } from 'firebase-admin/auth'
 
let firebaseApp: App
let auth: Auth
 
if (getApps().length === 0) {
  firebaseApp = initializeApp({
    credential: cert({
      projectId: process.env.FIREBASE_ADMIN_PROJECT_ID,
      clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL,
      // NOTE:
      //  envの設定方法次第で改行コードが\\nになってしまうため置換する
      //  参考: https://qiita.com/ikamirin/items/f84dafb5c6de5340c452
      privateKey: process.env.FIREBASE_ADMIN_PRIVATE_KEY?.replace(/\\n/g, '\n'),
    }),
  })
  auth = getAuth()
} else {
  firebaseApp = getApp()
  auth = getAuth()
}
 
export { auth, firebaseApp }

事前の準備は完了です!



データの取得(RSC)

getの構成

大きく3つ実装していきます

  1. 認証が必要なGETのエンドポイントの用意
  2. 1を取得するための関数を用意
  3. コンポーネントで2を呼び出す

1. 認証が必要なGETのエンドポイントの用意

いわゆるバックエンドのAPI部分です。

  • BearerトークンをAuthorizationヘッダーから取得
  • トークンを検証
  • 問題なければ必要な情報を取得して返す

というスタンダードな流れなものを作ります

app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/libs/firebase'
 
export async function GET(request: NextRequest) {
  // AuthorizationヘッダーからBearerトークンを取得
  const bearerToken = request.headers.get('Authorization')
  if (!bearerToken) {
    return NextResponse.json('Unauthorized', { status: 401 })
  }
  const accessToken = bearerToken.replace('Bearer ', '')
 
  let uid: string | undefined
 
  try {
    // firebase-adminのauthを使ってtokenを検証
    const decodedToken = await auth.verifyIdToken(accessToken)
    uid = decodedToken.uid
 
    console.log('firebase authのuid取得しました')
    console.log('uid:', uid)
  } catch {
    console.error('accessTokenが不正です')
    return NextResponse.json('Unauthorized', { status: 401 })
  }
 
  // uidを使いDBからユーザー情報等の問い合わせに必要な情報を取得する
  // ここでは参考用に適当なデータを返す
  const products = [
    { id: '1', name: 'product1', price: 100 },
    { id: '2', name: 'product2', price: 200 },
  ]
 
  return NextResponse.json({ products })
}

2. 1を取得するための関数を用意

1をコンポーネントから呼び出すための関数を作成します。

NextAuthに保存しているaccessTokenを取得ヘッダーに付与し、1のエンドポイントを呼び出します

app/api/products/loadProducts.ts
import { auth } from '@/auth'
 
type Product = {
  id: string
  name: string
  price: number
}
 
export async function loadProducts(): Promise<Product[]> {
  // NextAuthから認証情報を取得
  const session = await auth()
  const accessToken = session?.user?.accessToken
 
  // accessTokenがない(未ログイン)場合はエラーとする
  if (!accessToken) {
    throw new Error('accessToken is missing.')
  }
 
  const res = await fetch(`${process.env.NEXT_API_URL}/products`, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  })
 
  // トークンの有効期限切れなどの場合はエラーとする
  if (!res.ok) {
    throw new Error('Failed to fetch products.')
  }
 
  const data = await res.json()
  return data.products
}

3. コンポーネントで2を呼び出す

いよいよコンポーネントから呼び出します!


ポイントは

  • async をつけてRSC化する
  • loadProducts() をサーバー側で実行取得しレンダリングする
app/dashboard/page.tsx
import { loadProducts } from '@/app/api/products/loadProducts'
 
export default async function DashboardPage() {
  const products = await loadProducts()
 
  return (
    <div>
      <div>ダッシュボード</div>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            {product.name} - {product.price}
          </li>
        ))}
      </ul>
    </div>
  )
}


成功するとサーバーで取得した内容がレンダリングされます!

Access Tokenが正しく読み込めた場合


また

  • ログインしていない(AcceesTokenがない)
  • AccessTokenの有効期限が切れた後

など、失敗するケースで表示すると

Access Tokenを使った取得に失敗した場合

何かしらエラーが起きます。



データを投げる

postの構成

次にデータをサーバ側に投げる場合を見ていきます。



作る流れは取得の時と近く

  1. 認証が必要なPOSTのエンドポイントの用意
  2. 1を呼び出すServer Actionsを作成
  3. コンポーネントで2を呼び出す

となります。


1. 認証が必要なPOSTのエンドポイントの用意

先ほど作ったGET関数の下にPOSTのエンドポイントを作成します

app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/libs/firebase'
 
export async function GET(request: NextRequest) {
  // ・・省略・・
}
 
export async function POST(request: NextRequest) {
  // AuthorizationヘッダーからBearerトークンを取得
  const bearerToken = request.headers.get('Authorization')
  if (!bearerToken) {
    return NextResponse.json('Unauthorized', { status: 401 })
  }
  const accessToken = bearerToken.replace('Bearer ', '')
 
  // リクエストボディから商品名を取得
  const { name } = await request.json()
  if (!name) {
    return NextResponse.json('name is missing.', { status: 400 })
  }
 
  let uid: string | undefined
 
  try {
    const decodedToken = await auth.verifyIdToken(accessToken)
    uid = decodedToken.uid
 
    console.log('firebase authのuid取得しました')
    console.log('uid:', uid)
  } catch {
    console.error('accessTokenが不正です')
    return NextResponse.json('Unauthorized', { status: 401 })
  }
 
  // uidを使いDBからユーザー情報等の作成に必要な情報を取得する
  // 本来は商品の作成処理
  // ここでは参考用に適当なデータを返す
  const product = { id: '3', name, price: 300 }
  // 確認用のログ
  console.log('product:', product)
 
  return NextResponse.json({ product })
}

2. 1を呼び出すServer Actionsを作成

NextAuthに保存しているaccessTokenを取得ヘッダーに付与し、1のエンドポイントを呼び出します。



データ取得時との違いは

  • fetchの際に POST を指定
  • fetchで送信するデータをbody乗せる

ぐらい。


NextAuthの認証情報取得はRSC・Server Actions同じように書けるのが特徴で分かりやすいです ☺️

src/actions/createProduct.ts
'use server'
 
import { auth, signIn } from '@/auth'
import { redirect } from 'next/navigation'
 
type State = {
  message: string | null
}
 
export async function createProduct(previousState: State, formData: FormData): Promise<State> {
  // NextAuthから認証情報を取得
  // RSC用に用意した関数と同じように書ける
  const session = await auth()
  const accessToken = session?.user?.accessToken
 
  // accessTokenがない(未ログイン)場合はエラーとする
  if (!accessToken) {
    throw new Error('accessToken is missing.')
  }
 
  const name = formData.get('name')
 
  try {
    const res = await fetch(`${process.env.NEXT_API_URL}/products`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
      body: JSON.stringify({ name }),
    })
  } catch (error) {
    console.error(error)
    return { message: '商品作成に失敗しました' }
  }
 
  redirect('/dashboard')
}

3. コンポーネントで2を呼び出す

最後にコンポーネントでServer Actionsを呼び出します

app/products/page.tsx
'use client'
 
import { useFormState, useFormStatus } from 'react-dom'
import { createProduct } from '@/actions/createProduct'
 
function SubmitButton() {
  const { pending } = useFormStatus()
 
  return (
    <button type="submit" style={pending ? { opacity: '30%' } : {}} disabled={pending}>
      {pending ? '作成中...' : '作成'}
    </button>
  )
}
 
export default function CreateProductPage() {
  const [state, formAction] = useFormState(createProduct, { message: null })
 
  return (
    <div>
      <h1>商品 作成</h1>
      <form action={formAction}>
        <input name="name" placeholder="商品名" />
        <SubmitButton />
      </form>
 
      {state.message && <p>{state.message}</p>}
    </div>
  )
}

最初に作ったPOSTのエンドポイントが呼ばれ、最後のconsole.logが出力されました!

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