💭

Clerkを利用した認証処理とユーザーデータのDB登録

に公開

はじめに

Clerkを利用したNext.jsの認証機能とClerkで登録したユーザーデータをDBに登録する動きをまとめたドキュメントです。
今回はSPA方式(フロントエンド:Next.js、バックエンド:Gin)の構成になります。

触れる内容

  • Next.jsで作成するアプリケーションにClerkを利用した認証機能構築
  • Clerkで登録したユーザーデータをDBに登録する動き

前提

  • Clerkのアカウント作成とアプリケーション作成は済んでいるものとします
  • Next.jsのアプリケーションが作成されているものとします
  • GinのAPIサーバーが作成されているものとします

Clerkとは

  • Webアプリケーションやモバイルアプリケーション向けに認証・ユーザー管理機能を簡単に提供するプラットフォーム
  • メール / パスワードを利用した認証、ソーシャル(GoogleやLINEなど)を利用した認証に対応している

フロントエンド

Clerkをインストール

ターミナルで下記コマンドを実行します。

$ npm install @clerk/nextjs

middlleware.tsファイルの作成

Next.jsアプリケーションのルートディレクトリ(srcディレクトリを利用している場合はsrcディレクトリ直下)にmiddleware.tsファイルを作成します。

$ touch middleware.ts

or

$ touch src/middlware.ts
middleware.ts or src/middleware.ts
import { clerkMiddleware } from '@clerk/nextjs/server'

export default clerkMiddleware()

export const config = {
  matcher: [
    // Skip Next.js internals and all static files, unless found in search params
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    // Always run for API routes
    '/(api|trpc)(.*)',
  ],
}

共通レイアウトの設定

layout.tsxにClerkのコンポーネントを配置してアプリケーション全体で利用できるようにします。

app/layout.tsx or src/app/layout.tsx
import type { Metadata } from 'next'
import {
  ClerkProvider,
  SignInButton,
  SignUpButton,
  SignedIn,
  SignedOut,
  UserButton,
} from '@clerk/nextjs'
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: 'Clerk Next.js Quickstart',
  description: 'Generated by create next app',
}

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
          <header className="flex justify-end items-center p-4 gap-4 h-16">
            <SignedOut>
              <SignInButton />
              <SignUpButton>
                <button className="bg-[#6c47ff] text-white rounded-full font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 cursor-pointer">
                  Sign Up
                </button>
              </SignUpButton>
            </SignedOut>
            <SignedIn>
              <UserButton />
            </SignedIn>
          </header>
          {children}
        </body>
      </html>
    </ClerkProvider>
  )
}

Webhookの設定

Clerkで作成したユーザーデータをGinのAPIエンドポイントを叩いてDBに登録するにはWebhookを利用します。
ClerkではSvixを利用してセキュリティ強化を行なっています。
Svixについては公式をご確認いただけたらと思います。
https://docs.svix.com/receiving/verifying-payloads/why

$ npm install svix
app/api/clerk/route.ts or src/app/api/clerk/route.ts
import { WebhookEvent } from '@clerk/nextjs/server'
import { headers } from 'next/headers'
import { Webhook } from 'svix'

async function validateRequest(request: Request) {
  const webhookSecret = process.env.CLERK_WEBHOOK_SECRET || ""
  if (!webhookSecret) {
    throw new Error("CLERK_WEBHOOK_SECRET is not set.")
  }
  const payloadString = await request.text()
  const headerPayload = await headers();
  const svix_id = headerPayload.get("svix-id");
  const svix_timestamp = headerPayload.get("svix-timestamp");
  const svix_signature = headerPayload.get("svix-signature");

  if (!svix_id || !svix_timestamp || !svix_signature) {
    return new Response('Error occurred -- missing svix headers', {
      status: 400,
    });
  }

  const svixHeaders = {
    'svix-id': headerPayload.get('svix-id')!,
    'svix-timestamp': headerPayload.get('svix-timestamp')!,
    'svix-signature': headerPayload.get('svix-signature')!,
  }
  const wh = new Webhook(webhookSecret)
  return wh.verify(payloadString, svixHeaders) as WebhookEvent
}

export async function POST(request: Request) {
  try {
    const payload = await validateRequest(request)
    const backendUrl = process.env.BACKEND_API_URL;
    const backendSecret = process.env.BACKEND_API_SECRET;
    switch (payload.type) {
      case "user.created":
        try {
          // Gin APIにユーザー情報を送信
          const response = await fetch(`${backendUrl}/api/users`, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'Authorization': `Bearer ${backendSecret}`,
            },
            body: JSON.stringify({
              "clerk_user_id": payload.data.id,
              "first_name": payload.data.first_name,
              "last_name": payload.data.last_name,
              "email": payload.data.email_addresses[0]?.email_address,
              "profile_image_url": payload.data.image_url
            }),
          });
          if (!response.ok) {
            console.error('Failed to create user in Gin API:', await response.text());
          } else {
            console.log('User successfully created in database');
          }
        } catch (error) {
          console.error('Error creating user:', error);
        }
        break
    }
    return Response.json({ message: "Received" })
  } catch (err) {
    console.error('err:', err);
    return Response.json({ message: `Error: ${err}`})
  }
}

フロントエンド側の設定は以上です。

バックエンド

エンドポイント

main.go
func CreateUser(postgre_client *gorm.DB) gin.HandlerFunc {
	return func(c *gin.Context) {
		var user models.User
		if err := c.ShouldBindJSON(&user); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		postgre_client.Create(&user)
		c.JSON(http.StatusCreated, user)
	}
}

func authMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
			c.Abort()
			return
		}

		// Bearer トークンの検証
		token := strings.TrimPrefix(authHeader, "Bearer ")
		expectedToken := os.Getenv("BACKEND_API_SECRET")

		if token != expectedToken {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
			c.Abort()
			return
		}

		c.Next()
	}
}

func main() {
	if os.Getenv("GIN_MODE") != "release" {
		err := godotenv.Load()
		if err != nil {
			fmt.Println("Error loading .env file")
		}
	}

	// DB接続
	postgre_client, err := ConnectDB()
	if err != nil {
		log.Fatal("DB接続失敗:", err)
	}

	router := gin.Default()

	api := router.Group("/api")
	api.Use(authMiddleware())
	{
		api.POST("/users", CreateUser(postgre_client))
	}

	router.Run((":任意のport"))
}

バックエンド側の設定は以上です。

一連の動きについて

  1. ユーザーがNext.jsアプリケーションのSignUpボタンをクリック
  2. サインアップ情報をClerkに送信
  3. Clerkアプリケーションがユーザーアカウントを作成
  4. ClerkアプリケーションのWebhookを利用しNext.jsへ作成したユーザーデータを送信
  5. Next.jsからGinのエンドポイントにユーザーデータを送信
  6. DBに保存

シーケンス図に表すと下記のようになります。

最後に

駆け足となりましたが、以上がClerkを利用した認証処理とユーザーデータのDB登録の動きになります。間違っている点などありましたらコメントいただけますと幸いです。

Discussion