0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Next.js】Better Auth + Keycloakで認証を実装

0
Posted at

今後、Next.jsの認証のスタンダードになるっぽいBetter Authによる認証を実装してみました。

実装したソースコードはこちら

前提条件

prismaが導入されている前提です。

インストール

pnpm add better-auth

Better Auth用の環境変数の準備

.env
BETTER_AUTH_SECRET="(十分長いランダム)"  # ハッシュに利用されるシークレット
BETTER_AUTH_URL="http://localhost:3000"  # better authのベースURL
NEXT_PUBLIC_APP_URL=http://localhost:3000  # client componentのauthClientで利用

KEYCLOAK_ISSUER="https://keycloak.example.com/realms/MyRealm"
KEYCLOAK_CLIENT_ID="your-client-id"
KEYCLOAK_CLIENT_SECRET="your-client-secret"

Better Authインスタンスの作成

公式手順:

Auth.jsと異なり、Better AuthではDBが必須で、Better Auth専用のユーザーを管理するためのテーブルを構築する必要があります。
なので、Better AuthインスタンスにはIDプロバイダの情報の他に、DBとの接続設定(今回は Prisma + postgresql)が必要になります。

src/lib/auth.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { genericOAuth, keycloak } from "better-auth/plugins";
import { nextCookies } from "better-auth/next-js";
import prisma from "@/lib/prisma"


export const auth = betterAuth({
  // prismaの設定: https://www.better-auth.com/docs/adapters/prisma
  database: prismaAdapter(prisma, {
      provider: "postgresql"
  }),
  // プロバイダーヘルパーを利用した設定: https://www.better-auth.com/docs/plugins/generic-oauth#pre-configured-provider-helpers
  plugins: [
    genericOAuth({
      config: [
        keycloak({
          clientId: process.env.KEYCLOAK_CLIENT_ID!,
          clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
          issuer: process.env.KEYCLOAK_ISSUER!,
        }),
      ],
    }),
    // https://www.better-auth.com/docs/integrations/next#server-action-cookies
    nextCookies(),
  ],
});

参考:

DBテーブル作成

公式手順:
5: DBテーブル作成 - installation | Better Auth

以下のコマンドを実行し、Prismaのモデル定義 (prisma/schema.prisma) に better-authのテーブル定義を追記します。

pnpm dlx @better-auth/cli@latest generate
Packages: +178
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Progress: resolved 178, reused 173, downloaded 5, added 178, done
 WARN  Issues with peer dependencies found
.
└─┬ @better-auth/cli 1.4.7
  └─┬ better-auth 1.4.7
    └── ✕ unmet peer drizzle-orm@^0.41.0: found 0.33.0
✔ The file ./prisma/schema.prisma already exists. Do you want to overwrite the schema to the file? … yes
🚀 Schema was overwritten successfully!

追記されるモデル定義

prisma/schema.prisma
model User {
  id            String    @id
  name          String
  email         String
  emailVerified Boolean   @default(false)
  image         String?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
  sessions      Session[]
  accounts      Account[]

  @@unique([email])
  @@map("user")
}

model Session {
  id        String   @id
  expiresAt DateTime
  token     String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  ipAddress String?
  userAgent String?
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([token])
  @@index([userId])
  @@map("session")
}

model Account {
  id                    String    @id
  accountId             String
  providerId            String
  userId                String
  user                  User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  accessToken           String?
  refreshToken          String?
  idToken               String?
  accessTokenExpiresAt  DateTime?
  refreshTokenExpiresAt DateTime?
  scope                 String?
  password              String?
  createdAt             DateTime  @default(now())
  updatedAt             DateTime  @updatedAt

  @@index([userId])
  @@map("account")
}

model Verification {
  id         String   @id
  identifier String
  value      String
  expiresAt  DateTime
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt

  @@index([identifier])
  @@map("verification")
}

マイグレーション

# マイグレーションファイル生成
pnpm prisma migrate dev --name init --create-only

# マイグレーション実行
pnpm prisma migrate dev

# クライアントの再作成
pnpm prisma generate

こんな感じのテーブルが生成されます

スクリーンショット 2025-12-15 0.28.07.png (294.8 kB)

/api/auth/* APIルートの作成

公式手順:

ルートファイルを作成し、Better Authが必要とするAPIルートを生成します。

参考:

src/app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { GET, POST } = toNextJsHandler(auth);

どのようなAPIルートが生成されるか

残念ながら公式ドキュメントにはどのようなルートが生成されるかの仕様は載っていませんでしたが、betterAuthインスタンス作成時にプラグインとして、 openAPI() を設定するとSwaggerUIのようなAPIドキュメントが見られるようになります。

src/lib/auth.ts
// ... 省略 ...
import { openAPI } from "better-auth/plugins";


export const auth = betterAuth({
  // ... 省略 ...
  plugins: [
    // ... 省略 ...
    openAPI(),
  ],
});

アプリの起動

pnpm dev

http://localhost:3000/api/auth/reference にブラウザでアクセスするとAPIドキュメントを確認できます。

スクリーンショット 2026-02-01 13.31.00.png (336.0 kB) スクリーンショット 2026-02-01 13.31.23.png (179.4 kB)

クライアントインスタンスの作成

公式手順:
8. クライアントインスタンスの作成 - installation | Better Auth

認証サーバーとのやり取りを用意にするためのクライアントライブラリのインスタンスを生成します。
Better Authには、主要なWebフレームワーク向けのクライアントが標準で付属しています。

src/lib/auth-client.ts
// src/lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { genericOAuthClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
  plugins: [genericOAuthClient()],
});

参考:

確認画面の実装

レイアウト

src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`${geistSans.variable} ${geistMono.variable} antialiased`} >
        <div className="container mx-auto px-4 py-2">
          {children}
        </div>
      </body>
    </html>
  );
}

トップページ (/)

ログインボタンを配置して /login ページに遷移できるようにします。

src/app/page.tsx
import Link from "next/link"

export default function HomePage() {
  return (
    <main >
      <Link href="/login" className="text-blue-600 hover:underline">Login</Link>
    </main>
  );
}
スクリーンショット 2025-12-15 0.16.04.png (12.8 kB)

コンポーネント

ログインページ (/login)

ログインページでは「Sign in with Keycloak」ボタンを表示し、クリックするとKeycloakのログイン画面に飛ぶようにします。

src/app/login/page.tsx
import { LoginButton } from "@/components/ui/LoginButton"

export default function LoginPage() {
  return (
    <main className="container">
      <LoginButton></LoginButton>
    </main>
  )
}

ログインボタン

src/components/ui/LoginButton.tsx
'use client';
import { authClient } from "@/lib/auth-client"


export function LoginButton() {  // NOTE: client component ではasyncにできない
  const signIn = async () => {
    const {data, error} = await authClient.signIn.oauth2({
      providerId: "keycloak",
      callbackURL: "/dashboard",  // ログイン後にリダイレクトするURL
    })

    if (!data || error) {
      throw error
    }

    return data
  }

  return (
    <button className="px-4 py-2 rounded-md border" onClick={signIn} >
      Sign in with Keycloak
    </button>
  )
}
スクリーンショット 2025-12-15 0.16.23.png (14.3 kB)

ダッシュボードページ (/dashboard)

ダッシュボードページでは、ログインセッションを表示できるようにします。
サーバーサイドのセッションは src/lib/auth.ts の BetterAuthインスタンスから取得します。 ( auth.api.getSession()
クライアントサイドのセッション取得は src/lib/auth-client のクライアントインスタンスから行います。 (authClient.getSession())
その他ログアウトボタンも作成します。

src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { LogoutButton } from "@/components/ui/LogoutButton"
import { GetSessionClient } from "@/components/ui/getSessionClient"


export default async function DashboardPage() {
  // https://www.better-auth.com/docs/plugins/bearer#5-using-bearer-tokens-outside-the-auth-client
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) {
    redirect("/login");
  }

  return (
    <main>
      <div>
        <LogoutButton></LogoutButton>
      </div>
      <div>
        <h2 className="text-2xl">Server</h2>
        <pre className="bg-gray-900 text-gray-100 p-4 rounded-md overflow-x-auto">
          <code>{JSON.stringify(session, null, 2)}</code>
        </pre>
      </div>
      <div>
        <h2 className="text-2xl">Client</h2>
        <GetSessionClient></GetSessionClient>
      </div>
    </main>
  );
}

ログアウトボタン

src/components/ui/LogoutButton.tsx
'use client';
import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/navigation";

export function LogoutButton() {  // NOTE: client component ではasyncにできない
  const router = useRouter();
  const signOut = async () => {
    authClient.signOut({
      fetchOptions: {
        onSuccess: () => {
          router.push("/")
        }
      }
    })
  }
  return (
    <button className="px-4 py-2 rounded-md border" onClick={signOut} >
      Sign Out
    </button>
  )
}

クライアントサイドでのセッション取得ボタン

src/components/ui/getSessionClient.tsx
'use client';
import { authClient } from "@/lib/auth-client";
import { useState } from "react"

type Session = Awaited<ReturnType<typeof authClient.getSession>>;

export function GetSessionClient() {
  const [session, setSession] = useState<Session|null>(null)

  const getSessionClient = async () => {
    const session = await authClient.getSession();
    setSession(session)
  }


  return (
    <>
      <button className="px-4 py-2 rounded-md border" onClick={getSessionClient}>
        Get Session
      </button>
      <pre className="bg-gray-900 text-gray-100 p-4 rounded-md overflow-x-auto">
        <code>{JSON.stringify(session, null, 2)}</code>
      </pre>
    </>

  )
}
スクリーンショット 2025-12-15 0.15.27.png (221.6 kB)
server
{
  "session": {
    "expiresAt": "2025-12-21T15:16:40.824Z",
    "token": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
    "createdAt": "2025-12-14T15:16:40.824Z",
    "updatedAt": "2025-12-14T15:16:40.824Z",
    "ipAddress": "127.0.0.1",
    "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
    "userId": "3Ke1SmpTXG8klOvFoLBGWFilhesclZc7",
    "id": "7pyFWec2m10N5PTLBv9Xrkabwzy3aXSY"
  },
  "user": {
    "name": "Keita Midorikawa",
    "email": "xxxxxxxxxxxxxxx@example.com",
    "emailVerified": true,
    "image": null,
    "createdAt": "2025-12-14T08:12:03.479Z",
    "updatedAt": "2025-12-14T08:12:03.479Z",
    "id": "3Ke1SmpTXG8klOvFoLBGWFilhesclZc7"
  }
}
client
{
  "data": {
    "session": {
      "expiresAt": "2025-12-21T15:16:40.824Z",
      "token": "XXXXXXXXXXXXXXXXXXXXXXXXXX",
      "createdAt": "2025-12-14T15:16:40.824Z",
      "updatedAt": "2025-12-14T15:16:40.824Z",
      "ipAddress": "127.0.0.1",
      "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
      "userId": "3Ke1SmpTXG8klOvFoLBGWFilhesclZc7",
      "id": "7pyFWec2m10N5PTLBv9Xrkabwzy3aXSY"
    },
    "user": {
      "name": "Keita Midorikawa",
      "email": "xxxxxxxxxxxxxxx@example.com",
      "emailVerified": true,
      "image": null,
      "createdAt": "2025-12-14T08:12:03.479Z",
      "updatedAt": "2025-12-14T08:12:03.479Z",
      "id": "3Ke1SmpTXG8klOvFoLBGWFilhesclZc7"
    }
  },
  "error": null
}

Proxy(Middleware)によるルートの保護

データベースチェックを含む完全なセッションの検証

src/proxy.ts
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";

export async function proxy(request: NextRequest) {
    const session = await auth.api.getSession({
        headers: await headers()
    })
    const { pathname } = request.nextUrl

    const publicPages = ["/", "/login"];
    const isPublicPages = publicPages.includes(pathname)

    if (!isPublicPages && !session) {
        return NextResponse.redirect(new URL("/login", request.url));
    }

    return NextResponse.next();
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}

Cookieのセッションのみを確認

高速だがセキュリティー面は低い

src/proxy.ts
import {NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";

export function proxy(request: NextRequest) {
  const sessionCookie = getSessionCookie(request);
  console.log("session cookie: ", sessionCookie)
  const { pathname } = request.nextUrl;

  const publicPages = ["/", "/login"];
  const isPublicPages = publicPages.includes(pathname)

  if (!isPublicPages && !sessionCookie) {
    return NextResponse.redirect(new URL("/login", request.url))
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}

/dashboard ページのリダイレクト処理を削除

src/app/dashboard/page.tsx
// ... 省略 ...

export default async function DashboardPage() {
  // ... 省略 ...

  // 削除
  // if (!session) {
  //    redirect("/login");
  //  }

  return (
    // ... 省略 ...
  );
}

参考情報

cli: https://www.better-auth.com/docs/concepts/cli
keycloak: https://www.better-auth.com/docs/plugins/generic-oauth#pre-configured-provider-helpers
prisma: https://www.better-auth.com/docs/adapters/prisma
next-cookie: https://www.better-auth.com/docs/integrations/next#server-action-cookies
nextjs integrations: https://www.better-auth.com/docs/integrations/next

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?