Next.jsにおける「middlewareを使わない」認証のベストプラクティス
前提知識: APIでmiddleware.tsを使わない
この記事ではこのような構造を作りますが、middleware.tsはAPIに適用すべきでしょうか?
- /api/foo -> bearer認証
- /dashboard/:path* -> ログイン認証
公式ドキュメントでは「理想的な認証と認可の実装パターン」が例示されています。これに照らし合わせると、APIルートの認証はmiddleware.tsでよいのか微妙なところです。
ただ、APIキーの認証が「userがAPIを使う認可」を目的とするなら、それをmiddleware.tsに書くことはベストプラクティスではありません。
また、 公式の例では、そもそもmatcherにAPIルートが含まれておりません。
以上のことから、middleware.tsの適用範囲はAPI以外になります。
筆者も認識を間違っていたため、ここで正しい実装を例示します。
なお、Tsu0607さんの記事により、認識の誤りに気付きました。心から感謝申し上げます。
1. middleware.ts
いきなり使ってるじゃねえか!
... Cookie等のヘッダで判断できて、なおかつデータアクセス層でも認証・認可している場合のみ、 userのリダイレクト処理はMiddleware.tsに書いて問題ありません。
こちらの記事で知ったのですが、NEMOというライブラリを使います。
npm i @rescale/nemo
以下はbetter-authで認証している場合の例です。nodejsランタイムを使っています。御覧のように、APIの認証を含みません。
import { createNEMO, MiddlewareConfig, NextMiddleware } from "@rescale/nemo";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
const sessionAuthentication: NextMiddleware = async (request) => {
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.redirect(new URL("/sign-in", request.url));
}
} catch {
return NextResponse.redirect(new URL("/sign-in", request.url));
}
};
const middlewares = {
"/dashboard/:path*": [sessionAuthentication],
} satisfies MiddlewareConfig;
export const middleware = createNEMO(middlewares);
export const config = {
runtime: "nodejs",
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
*/
"/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
],
};
2. Page.tsx
フロントエンドの詳しい認証は割愛します。この方法は使うライブラリに依存しているからです。
例えば、公式ドキュメントではこのような関数を定義しています。
import 'server-only'
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'
export const verifySession = cache(async () => {
const cookie = (await cookies()).get('session')?.value
const session = await decrypt(cookie)
if (!session?.userId) {
redirect('/login')
}
return { isAuth: true, userId: session.userId }
})
export const getUser = cache(async () => {
const session = await verifySession()
if (!session) return null
// 以下略
})
ここがNext.jsの不便なところで、 Rails/Laravelなら「ミドルウェア」として書ける処理を、自分で各ActionやLoaderの前に挿入する必要があります。
layout.tsxに書く方法もありますが、バージョンにより挙動が統一されない危うさがあるので、ここでは触れません。また、結局ページ内容のロードに対する認可の判断が行えないので、layout.tsxを認証レイヤーとするのは誤りだと考えています。
Better Authの例
better authのexampleでは、このようにcatchブロックでリダイレクトしていました。
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
export default async function DashboardPage() {
const [session] = await Promise.all([
auth.api.getSession({
headers: await headers(),
})
]).catch((e) => {
console.log(e);
throw redirect("/sign-in");
});
return (
<div>
{/* ここにユーザー情報 */}
</div>
);
}
ただ、これはあくまでページレベルの話で、実際は先述したようにデータアクセス層でもチェックすべきです。
3. Route Handler
全てのroute.tsに同じロジックを配置するのは面倒なので、 next-zod-route でMiddlewareを定義します。
まだ採用数の少ないライブラリですが、同じような目的を達成できる中で、ついでにリクエストボディのバリデーションができるため採用しました。
npm i next-zod-route
3-1. まず"ミドルウェア"を定義する
ここでいう"ミドルウェア"は、一般的なMVCフレームワークの「ミドルウェア」の意味合いで使っています。
next-zod-routeのライブラリ作者もこういう命名をしています。middleware.tsとは関係ないので注意。
MiddlewareFunction として非同期関数を実装します。ここでは、t3-envで定義した API_KEY とBearerトークンを比較しています。
import crypto from "node:crypto";
import { NextResponse } from "next/server";
import type { MiddlewareFunction } from "next-zod-route";
import { env } from "@/env";
function safeCompare(a: string, b: string) {
const aBuf = Buffer.from(a);
const bBuf = Buffer.from(b);
return aBuf.length === bBuf.length && crypto.timingSafeEqual(aBuf, bBuf);
}
export function checkBearerToken(headers: Headers) {
if (!env.API_KEY) {
return false;
}
const authHeader = headers.get("Authorization") || "";
const token = authHeader.match(/^Bearer\s+(.+)$/i)?.[1];
return token && safeCompare(token, env.API_KEY);
}
export const apiKeyMiddleware: MiddlewareFunction = async ({
next,
request,
}) => {
if (!checkBearerToken(request.headers)) {
return NextResponse.json(
{ message: "forbidden" },
{
status: 403,
},
);
}
return next();
};
3-2. 各ルートに適用する
- useで共通の処理を定義します。
- bodyでスキーマを定義します。
import { NextResponse } from "next/server";
import { createZodRoute } from "next-zod-route";
import { z } from "zod";
import { apiKeyMiddleware } from "@/lib/api";
const bodySchema = z.object({
// ここにスキーマ
});
export const POST = createZodRoute()
.use(apiKeyMiddleware)
.body(bodySchema)
.handler(async (_request, context) => {
try {
// ここに実際の処理
return NextResponse.json(
{
message: "OK",
},
{
status: 200,
},
);
} catch (e) {
console.error(e);
return NextResponse.json(
{
message: "Internal server error",
},
{
status: 500,
},
);
}
});
感想
ファイルベースルーティングは人類には早かった。
素晴らしい提案をしよう。お前もルート定義ファイルを書かないか?
Discussion