Skip to content

【Next.js】Auth.jsとKeycloakで実装する認証基盤

はじめに

仕事で Next.js を使っているプロジェクトがあり、コーディングを担当している方からコードレビューの依頼を受けました。私自身 Next.js はあまり触ったことがなく、Auth.js と組み合わせてログイン機能を実装しているコードを見てレビューが難しい状況でした。

そこで今回は、Next.js と Auth.js を使ってログイン機能を実装する方法を学ぶことにしました。

成果物

https://github.com/kntks/blog-code/tree/main/2025/01/nextjs-keycloak-login

環境

バージョン

バージョン
MacVentura 13.2.1
Keycloak26.0.7
Docker26.0.0
Docker Composev2.24.5
Terraform1.10.3
Node.jsv22.12.0

環境構築

Keycloakのセットアップ

Keycloak を docker compose で起動します。

compose.yaml
services:
keycloak:
image: quay.io/keycloak/keycloak:26.0.7
ports:
- target: 8080
published: 8080
protocol: tcp
mode: host
environment:
- KEYCLOAK_ADMIN=admin
- KEYCLOAK_ADMIN_PASSWORD=admin
command: ["start-dev"]
volumes:
- ./keycloak:/opt/keycloak/data

Terraform を使用するために Keycloak側のセットアップ が必要です。

terraform/terraform.tfvars
client_id = "terraform"
client_secret = "client secretをコピペ"
url = "http://localhost:8080"

terraform apply で Keycloak の設定を行います。
この実行により、Keycloak にレルム、クライアント、ユーザーを作成します。

Terminal window
cd terraform
terraform init
terraform plan
terraform apply -auto-approve

プロジェクト作成

create-next-app を使ってプロジェクトを作成します。

Terminal window
pnpm dlx create-next-app nextjs-keycloak-login --ts --app --turbopack --import-alias "@/*" --tailwind --src-dir --no-eslint --use-pnpm

使わない画像データを削除します。

Terminal window
rm -f src/app/favicon.ico public/*

linter、formatter の設定

eslint の代わりに、biome を使用します。

Terminal window
pnpm add --save-dev --save-exact @biomejs/biome
pnpm dlx @biomejs/biome init

package.json に biome のコード整形用スクリプトを追加します。

package.json
{
...
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"fmt": "biome check --write ./src"
},
...
}

Auth.jsのインストール

Terminal window
pnpm add -E next-auth@beta

環境変数を設定します。

.env.local
AUTH_KEYCLOAK_ID=myapp
AUTH_KEYCLOAK_SECRET=client secretをコピペ
AUTH_KEYCLOAK_ISSUER=http://localhost:8080/realms/myrealm

shadcn/uiのインストール

Terminal window
pnpm dlx shadcn@latest add card button

実装

前のセクションで環境構築が済んだので、実装を進めます。

Auth.jsの設定

src/auth.ts
import NextAuth from "next-auth";
import Keycloak from "next-auth/providers/keycloak";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [Keycloak],
});
src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

参考:

ログインページの作成

今回はログイン完了後、/hello にリダイレクトさせます。

先にログインボタンを作成します。

src/components/button.tsx
import { signIn, signOut } from "@/auth";
import { Button } from "@/components/ui/button";
export const LoginButton: React.FC = () => {
return (
<Button
onClick={async () => {
"use server";
await signIn("keycloak", { redirectTo: "/hello" });
}}
>
Keyclokでログイン
</Button>
);
};
export const LogoutButton: React.FC = () => {
return (
<Button
variant="secondary"
onClick={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
ログアウト
</Button>
);
}

トップページはログインボタンのみ配置します。

src/app/page.tsx
import { LoginButton } from "@/components/login";
const Page: React.FC = () => {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<LoginButton />
</main>
</div>
);
};
export default Page;

見た目は以下のようになります。 login-page.webp

ボタンをクリックすると Keycloak のログインページに遷移します。
今回は、Terraform でユーザーとパスワードともに myuser として作成しています。 keycloak-login-page.webp

ログイン後のページの作成

Keycloak でログイン後、/hello にリダイレクトされます。
/hello にはログイン情報を表示するコンポーネントを配置します。

src/app/hello/page.tsx
import { Hello } from "@/components/hello";
const Page: React.FC = () => {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<Hello />
</main>
</div>
);
};
export default Page;
src/components/hello.tsx
import { auth } from "@/auth";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { LogoutButton } from "./button";
export const Hello: React.FC = async () => {
const session = await auth();
return (
<Card className="w-[500px]">
<CardHeader>
<CardTitle>ようこそ</CardTitle>
<CardDescription>
{session?.user?.name}さん、ログインに成功しました!
</CardDescription>
</CardHeader>
<CardContent>
<p>メールアドレス: {session?.user?.email}</p>
<p>ユーザー情報:</p>
<pre className="mt-2 w-[450px] rounded-md bg-slate-950 p-4">
<code className="text-white">
{JSON.stringify(session?.user, null, 2)}
</code>
</pre>
</CardContent>
<CardFooter className="flex justify-between">
<LogoutButton />
</CardFooter>
</Card>
);
};

Keycloak でログイン後、/hello にリダイレクトされます。 hello-page.webp

SessionProvider の設定

今回はログインページと Hello ページ共に Server Components を使用したため、Auth.js の useSession を必要としませんでした。

しかし、Client Components(ファイルの先頭に "use client" を書く React コンポーネント)でセッション情報を取り扱う場合、SessionProvider を設定しておく必要があります。

今後追加で実装することを想定して、SessionProvider を設定しておきます。

src/providers.tsx
"use client";
import { SessionProvider } from "next-auth/react";
const Providers: React.FC<React.PropsWithChildren> = ({ children }) => {
return <SessionProvider>{children}</SessionProvider>;
};
export default Providers;
src/app/layout.tsx
// 省略
import Providers from "@/providers";
// 省略
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Providers>{children}</Providers>
</body>
</html>
);
}

SessionProvider でラップされたコンポーネント内で useSession フックを使用できるようにします。以下は公式のサンプルコードです。

src/components/hello.tsx
"use client"
import { useSession } from "next-auth/react"
export default function Dashboard() {
const { data: session } = useSession()
if (session?.user?.role === "admin") {
return <p>You are an admin, welcome!</p>
}
return <p>You are not authorized to view this page!</p>
}

引用:https://authjs.dev/getting-started/session-management/get-session

ミドルウェアの設定

これまでの設定により、Keycloak を使用したログイン機能が実装できました。
ただし、現状ではブラウザから /hello に直接アクセスすると、認証なしで hello コンポーネントが表示されてしまいます。 nologin-hello-page

そこで、/hello などの認証が必要なページへのアクセスについて、未ログインの場合はログインページへ自動的にリダイレクトする設定を行いたいと思います。

この要件は、Next.js のミドルウェアを使用することで実現でき、Next.js の公式ドキュメントでも Middleware のユースケースとして紹介されています。

  • 認証と認可: ユーザーの身元を確認し、特定のページやAPIルートへのアクセスを許可する前にセッションCookieを確認します。
  • サーバーサイドリダイレクト: 特定の条件(例:ロケール、ユーザーロール)に基づいて、サーバーレベルでユーザーをリダイレクトします。
  • パスの書き換え: A/Bテスト、機能展開、またはレガシーパスのサポートのために、リクエストプロパティに基づいてAPIルートやページへのパスを動的に書き換えます。
  • ボット検出: ボットトラフィックを検出しブロックすることで、リソースを保護します。
  • ログと分析: ページやAPIがリクエストを処理する前に、リクエストデータを収集し分析してインサイトを得ます。 -フィーチャーフラグ: 機能展開やテストをスムーズに行うために、機能を動的に有効化または無効化します。

引用:Middleware - Next.js

ミドルウェアを設定するには、プロジェクトのルートに middleware.ts というファイルを作成する必要があります。

Use the file middleware.ts (or .js) in the root of your project to define Middleware. For example, at the same level as pages or app, or inside src if applicable.

訳:ミドルウェアを定義するには、プロジェクトのルートにあるmiddleware.ts(または.js)ファイルを使用します。 例えば、pagesやappと同じレベルか、該当する場合はsrcの中です。

引用:Convention - Next.js

どこに認可ロジックを書くか

ロジックを書く場所は2通り存在し、middleware.tsauth.ts のどちらかです。
今回は、auth.ts に書く方法を採用します。理由は、認可以外のミドルウェアを設定する場合、上手に分離できるためです。
ミドルウェアで認可のみ行う場合は、middleware.ts でも auth.ts でもどちらでも良いと思います。

middleware.ts に書く場合

auth メソッドにロジックを追加することで拡張できます。

src/middleware.ts
import { auth } from "./auth"
export default auth((req) => {
// req.auth
})
auth.ts に書く場合

callbacks.authorized にロジックを追加することで拡張できます。このコールバックは、ミドルウェアを設定した場合に実行されます。

ミドルウェアで認可のみ行う場合、middleware.ts には auth メソッドをそのまま export します。

src/middleware.ts
export { auth as middleware } from "@/auth";

しかし、他のミドルウェアを設定することを考慮して以下のように compose 関数を実装します。

src/middleware.ts
import { auth } from "@/auth";
import {
type MiddlewareConfig,
type NextFetchEvent,
type NextMiddleware,
type NextRequest,
NextResponse,
} from "next/server";
type NextAuthMiddleware = typeof auth;
// ref: https://github.com/nextauthjs/next-auth/discussions/8961#discussioncomment-9625060
function compose(
...fns: (NextAuthMiddleware | NextMiddleware)[]
): NextMiddleware {
const validMiddlewares = fns.filter((fn) => typeof fn === "function");
return async (req: NextRequest, event: NextFetchEvent) => {
for (const fn of validMiddlewares) {
const response = await fn(req, event);
if (response instanceof NextResponse || response instanceof Response)
return response;
}
return NextResponse.next();
};
}
function log(req: NextRequest, _event: NextFetchEvent) {
// 別のミドルウェアを呼び出す
console.log("request path", req.nextUrl.pathname);
}
const middlewares: NextMiddleware[] = [log, auth];
export default compose(...middlewares);
export const config = {
matcher: [
"/((?!api/auth|_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
],
} satisfies MiddlewareConfig;

middleware.ts に書いた config の mathcer では、トップページである “http://localhost:3000” にリクエストした場合でもミドルウェアの処理が実行されてしまいます。そのため、トップページではミドルウェアを実行しないようにするために、if (request.nextUrl.pathname === "/") return true; という条件を入れています。

それ以外のページにアクセスした場合は、auth の存在を確認し、auth が存在しない場合は、リダイレクトします。

src/auth.ts
import NextAuth from "next-auth";
import Keycloak from "next-auth/providers/keycloak";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [Keycloak],
callbacks: {
authorized: async ({ request, auth }) => {
if (request.nextUrl.pathname === "/") return true;
if (!auth) {
return NextResponse.redirect(new URL("/", request.nextUrl.origin));
}
return true;
},
}
});

ログイン後、Cookie に保存されたセッショントークンを削除して、再度 /hello にアクセスしてみます。 nextjs-auth

問題なくログインページにリダイレクトされました。

参考:

まとめ

今回は、Next.js と Auth.js を使ってログイン機能を実装する方法を学びました。具体的には、ミドルウェアを使用して認証を行い、認証されていないユーザーをサインインページにリダイレクトする方法を実装しました。また、複数のミドルウェアを組み合わせて使用する方法についても学びました。

この実装により、認証が必要なページへのアクセスを制御し、セキュリティを強化できます。今回の実装の途中で、Auth.js のコードリーディングをして分かったこともありました。

今後は、さらに詳細な認可制御や、セッションの有効期限の管理など、より高度な機能を追加して記事を書いていきたいと思います。