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)
データ送信(post)
以下の順番で実装を進めます
- NextAuth.jsの設定
- Firebase Authenticationの設定
- ログイン
- AccessTokenの保存
- データ取得(get)
- データ送信(post)
アクセストークンのリフレッシュ処理は別記事に記載します
NextAuthの初期設定
NextAuth.jsのパッケージを追加します
2024.11.19現在、B版ですが今後標準となっていくであろうv5を使っていきます
$ npm install next-auth@beta
v4 と v5 の更新内容は以下になっています
- メソッドが統一され分かりやすくなった
- Route Handler・API Route でも利用出来るようになった
というのが大きな違いですね
ページの用意
ログインページ と ログイン後に遷移するダッシュボードページ を用意します
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>
)
}
export default function DashboardPage() {
return (
<h1>ダッシュボード</h1>
)
}
ログイン処理の雛形を用意
NextAuthの設定ファイルを用意しログインが実行出来ることを確認します
まずテストとして
- email・・
test@test.com
- password・・
password
↑の固定値を受け取ったらログインが出来るようにします
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
内の処理が呼び出されます
'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を呼び出します
'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に新しいプロジェクトを作成していきます
メールとパスワード でのログインを行えるようにするため
サイドバーからAuthenticationを選択し「メール/パスワード」を選択し有効にします
Firebase Authenticationでメール/パスワードを行えるようにする準備が出来ました!
ログイン用のエンドポイントの用意
Firebase Auth REST API を使ってログイン用のエンドポイントを用意します。
FirebaseのプロジェクトのAPI key を確認し環境変数に設定します
# Firebase上で取得した ウェブAPIキー を設定する
FIREBASE_API_KEY={{ウェブAPIキーを設定}}
ログイン用のエンドポイントを Route Handler を使って用意します。
最低限の挙動を確認したいので
- エラーハンドリングなし
- Firebase Auth REST APIで正常に値を取得出来たら、idTokenをaccessTokenに見立ててレスポンスに含める
でいきます
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 上にアカウントを作っておきます
NextAuthのログイン処理部分でRoute Handlerを呼び出します
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
という値を保存・利用しても値が存在しないエラーが起きないようにします
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を利用可能にする
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を用意してみます
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が表示されます
そして、ブラウザリロードしてもaccessTokenが取得出来ていることが分かります。
これはcookieの authjs.session-token
にaccessTokenが追加で保存されているためです。
暗号化されているのでパッと見だと全く分かりませんが、
accessTokenを保存するようにした前後でデータ量がかなり大きくなっていることが分かります。
before | after |
---|---|
Access Tokenを用いてアクセスする
最後です!
認証が必要な処理に対してaccessTokenを付与し問い合わせを行います。
データを取得する場合(RSC)と、データを投げる場合(Server Actions)に分けて記載します
前準備
今回はサーバー側で受け取ったaccessTokenの認可もNext.js上で行うため firebase-admin を導入します
$ pnpm install firebase-admin
Next.jsでfirebase-adminを使えるようにするため、Fibaseのプロジェクトページで秘密鍵を生成
以下のような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のプロジェクトと連携出来るようにします
FIREBASE_ADMIN_PROJECT_ID={{project_id の値}}
FIREBASE_ADMIN_CLIENT_EMAIL={{client_email の値}}
FIREBASE_ADMIN_PRIVATE_KEY="{{private_key の値}}"
firebaseの設定を行うファイルを追加しておきます
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)
大きく3つ実装していきます
- 認証が必要なGETのエンドポイントの用意
- 1を取得するための関数を用意
- コンポーネントで2を呼び出す
1. 認証が必要なGETのエンドポイントの用意
いわゆるバックエンドのAPI部分です。
- BearerトークンをAuthorizationヘッダーから取得
- トークンを検証
- 問題なければ必要な情報を取得して返す
というスタンダードな流れなものを作ります
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のエンドポイントを呼び出します
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()
をサーバー側で実行取得しレンダリングする
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>
)
}
成功するとサーバーで取得した内容がレンダリングされます!
また
- ログインしていない(AcceesTokenがない)
- AccessTokenの有効期限が切れた後
など、失敗するケースで表示すると
何かしらエラーが起きます。
データを投げる
次にデータをサーバ側に投げる場合を見ていきます。
作る流れは取得の時と近く
- 認証が必要なPOSTのエンドポイントの用意
- 1を呼び出すServer Actionsを作成
- コンポーネントで2を呼び出す
となります。
1. 認証が必要なPOSTのエンドポイントの用意
先ほど作ったGET関数の下にPOSTのエンドポイントを作成します
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同じように書けるのが特徴で分かりやすいです ☺️
'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を呼び出します
'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が出力されました!