Zenn
🔐

【パスキー・2FA・OAuth】Better Authで作るモダン認証システム

2025/02/18に公開
23

はじめに

今回は、Better Authというライブラリによる認証システムの実装について解説します。
Better Authは2024年11月22日にv1.0がリリースされたばかりの、TypeScript製の新しい認証ライブラリです。

https://www.better-auth.com/

公式ページにあるように、フレームワーク非依存なことがメリットであるため、一般的に使われるフレームワークでは、Better Authをフルに活用することができます。

また、パスキー認証といった最新の認証標準にも対応しています。
もちろんEmail・PasswordやUsernameといった認証規格にも対応しております。

そして、今回行うことですが、Better Authを用いて認証の主要機能である、以下の機能の実装解説をしていきます。

  • Email・Password認証
  • Email 2FA(2要素)認証
  • GitHub OAuth認証
  • パスキー認証

また、実装済みのソースコードは以下です。

https://github.com/sc30gsw/next15-better-auth-tutorial

実装解説の前に、軽く有名なライブラリとの比較をして、なぜBetter Authなのかというモチベーションの部分に触れていこうと思います。

Auth.jsとの比較

TypeScript(特にReactやNext.js)を使用する場合、Auth.jsを採用するケースが多く見られます。
Auth.jsは旧名称がNextAuthであることからも分かるように、現在でもNext.jsとの親和性が高い反面、他のフレームワークでの利用には手間がかかることがあり、フレームワーク依存度が比較的高いと言えます。

また、Auth.jsではパスキー認証の機能自体は提供されていますが、本番環境での使用は非推奨とされています。そのため、現時点でライブラリを用いてパスキー認証を実装する場合は、Better Authを選択するのが実用的と考えられます。

https://authjs.dev/getting-started/providers/passkey

さらに、Auth.jsでは認証設定のconfigファイルが肥大化しやすい傾向がありますが、Better Authはプラグインの導入や提供されるAPI・APIクライアントを活用できるため、ライブラリ関連の記述が少なく済むのも大きなメリットと言えるでしょう。

導入が長くなりましたが、ここからは実装の解説に移ります。

実装解説

まず、今回ですが、主なスタックとして以下の技術スタックで実装を行っていきます。

Better Authの導入

まずは、Better Authの導入をしていきましょう。
また、環境変数をtype safeに扱うため、@t3-oss/env-nextjszodも追加します。

fish
bun add better-auth @t3-oss/env-nextjs zod

続いてBetter AuthのSecret KeyとBase URLを設定します。
以下のコマンド実行し、Secret Keyを取得します。

fish
openssl rand -base64 32

出力されたものをコピーし、.envに環境変数を記載します。
Better AuthのAPI Client用にNEXT_PUBCLI_APP_URLもこの時点で予め追加しておきます。

.env
BETTER_AUTH_SECRET = your-secret-key
BETTER_AUTH_URL = http://localhost:3000
NEXT_PUBLIC_APP_URL = http://localhost:3000

そして、環境変数を型管理するファイルを作成し、以下のようにします。
これで、環境変数をtype safeに扱うことが可能です。
※ 以降の解説でも環境変数の追加がありますが、その際は解説を割愛するので、適宜、追加する必要がありますので、ご認識していただければと思います。

env.ts
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
  server: {
    BETTER_AUTH_SECRET: z.string(),
    BETTER_AUTH_URL: z.string().url(),
  },
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
  },
  runtimeEnv: {
    BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
    BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
  },
})

次に、Auth Instanceを定義していきます。

Auth Instanceの定義

まずは、今回実装する各認証に必要なpluginとメール・パスワード認証が使用できるように設定を追加します。

lib/auth/auth.ts
import { betterAuth } from 'better-auth'
import { nextCookies } from 'better-auth/next-js'
import { twoFactor } from 'better-auth/plugins'
import { passkey } from 'better-auth/plugins/passkey'

export const auth = betterAuth({
  emailAndPassword: {
    enabled: true,
  },
  plugins: [
    twoFactor(),
    passkey(),
    nextCookies(),
  ],
})

ついでに、Better AuthのAPI Client側のInstanceも作成しておきます。
API Client側では2FAとPasskey認証を使用するため、pluginは2つのみ設定しておきます。

lib/auth/auth-client.ts
import { twoFactorClient } from 'better-auth/plugins'
import { passkeyClient } from 'better-auth/plugins/passkey'
import { createAuthClient } from 'better-auth/react'
import { env } from '~/env'

export const authClient = createAuthClient({
  baseURL: env.NEXT_PUBLIC_APP_URL,
  plugins: [twoFactorClient(), passkeyClient()],
})

Route Handler作成

今回はNext.jsを使用するので最後に、Next.jsのRoute HandlerでBetter Auth APIのリクエストを受け付けるよう設定していきます。
これはCatch-all Segmentsでファイルを作成します。

app/api/auth/[...better-auth]/route.ts
import { toNextJsHandler } from 'better-auth/next-js'
import { auth } from '~/lib/auth/auth'

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

これで、Better Authの設定は完了です。

次にDBの構築を行います。

DBのセットアップ

今回はsqlite baseのTursoとORMにDrizzleを使っていくので、以下のページを参考に、セットアップしていきます。
※ Drizzleのドキュメントの手順そのままなので、解説は割愛致します

https://orm.drizzle.team/docs/tutorials/drizzle-with-turso

DB・Drizzleのセットアップが完了したら、Better Auth側にもDBの設定を追加します。

Better AuthとDrizzleの連携

以下のようにdatabaseプロパティに、drizzleAdapterを渡して各種値を設定します。

lib/auth/auth.ts
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { nextCookies } from 'better-auth/next-js'
import { twoFactor } from 'better-auth/plugins'
import { passkey } from 'better-auth/plugins/passkey'
import { env } from '~/env'
import { db } from '~/lib/db/db'
import * as schema from '~/lib/db/schema'

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: 'sqlite',
    schema: schema,
    usePlural: true,
  }),
  emailAndPassword: {
    enabled: true,
  },
  plugins: [
    twoFactor(),
    passkey(),
    nextCookies(),
  ],
})

databaseの設定を記述したら、CLIにてschemaを作成していきます。
Better Authのすごいところは認証のschemaをコピペするのではなく、CLIで自動生成できるところです。
このようにすると自動で任意の場所にschemaが作成されます

fish
bunx @better-auth/cli generate

ちなみに、作成されたSchemaは以下になります。
リレーションの設定やtimestampの$defaultFnなどの細かい部分は自分で修正を加える必要がありますが、各種カラムや型などは修正が不要です。

lib/db/schema.ts
import { relations, sql } from 'drizzle-orm'
import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core'

export const users = sqliteTable('users', {
  id: text('id')
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  emailVerified: int('email_verified', { mode: 'boolean' }).notNull(),
  image: text('image'),
  createdAt: int('created_at', { mode: 'timestamp' })
    .default(sql`(CURRENT_TIMESTAMP)`)
    .notNull(),
  updatedAt: int('updated_at', { mode: 'timestamp' })
    .$onUpdate(() => new Date())
    .notNull(),
  twoFactorEnabled: int('two_factor_enabled', { mode: 'boolean' }),
})

export const sessions = sqliteTable('sessions', {
  id: text('id').primaryKey(),
  expiresAt: int('expires_at', { mode: 'timestamp' }).notNull(),
  token: text('token').notNull().unique(),
  createdAt: int('created_at', { mode: 'timestamp' })
    .default(sql`(CURRENT_TIMESTAMP)`)
    .notNull(),
  updatedAt: int('updated_at', { mode: 'timestamp' })
    .$onUpdate(() => new Date())
    .notNull(),
  ipAddress: text('ip_address'),
  userAgent: text('user_agent'),
  userId: text('user_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
})

export const accounts = sqliteTable('accounts', {
  id: text('id').primaryKey(),
  accountId: text('account_id').notNull(),
  providerId: text('provider_id').notNull(),
  userId: text('user_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  accessToken: text('access_token'),
  refreshToken: text('refresh_token'),
  idToken: text('id_token'),
  accessTokenExpiresAt: int('access_token_expires_at', {
    mode: 'timestamp',
  }),
  refreshTokenExpiresAt: int('refresh_token_expires_at', {
    mode: 'timestamp',
  }),
  scope: text('scope'),
  password: text('password'),
  createdAt: int('created_at', { mode: 'timestamp' })
    .default(sql`(CURRENT_TIMESTAMP)`)
    .notNull(),
  updatedAt: int('updated_at', { mode: 'timestamp' })
    .$onUpdate(() => new Date())
    .notNull(),
})

export const verifications = sqliteTable('verifications', {
  id: text('id').primaryKey(),
  identifier: text('identifier').notNull(),
  value: text('value').notNull(),
  expiresAt: int('expires_at', { mode: 'timestamp' }).notNull(),
  createdAt: int('created_at', { mode: 'timestamp' })
    .default(sql`(CURRENT_TIMESTAMP)`)
    .notNull(),
  updatedAt: int('updated_at', { mode: 'timestamp' })
    .$onUpdate(() => new Date())
    .notNull(),
})

export const twoFactors = sqliteTable('two_factors', {
  id: text('id').primaryKey(),
  secret: text('secret').notNull(),
  backupCodes: text('backup_codes').notNull(),
  userId: text('user_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
})

export const passkeys = sqliteTable('passkeys', {
  id: text('id').primaryKey(),
  name: text('name'),
  publicKey: text('public_key').notNull(),
  userId: text('user_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  credentialID: text('credential_i_d').notNull(),
  counter: int('counter').notNull(),
  deviceType: text('device_type').notNull(),
  backedUp: int('backed_up', { mode: 'boolean' }).notNull(),
  transports: text('transports'),
  createdAt: int('created_at', { mode: 'timestamp' }),
})

// 🛠 users テーブルのリレーション定義
export const usersRelations = relations(users, ({ many }) => ({
  accounts: many(accounts),
  twoFactors: many(twoFactors),
}))

// 🛠 accounts テーブルのリレーション定義
export const accountsRelations = relations(accounts, ({ one }) => ({
  user: one(users, {
    fields: [accounts.userId],
    references: [users.id],
  }),
}))

// 🛠 twoFactors テーブルのリレーション定義
export const twoFactorsRelations = relations(twoFactors, ({ one }) => ({
  user: one(users, {
    fields: [twoFactors.userId],
    references: [users.id],
  }),
}))

schemaの作成・修正ができたら、DBに反映していきます。

fish
bunx @better-auth/cli migrate
# or
bunx drizzle-kit migrate

ちなみに、すべてのschema定義がusersのように複数形である場合は、usePluralをオンにするだけで、以下のように個別にschema定義をする必要がないので、schema定義は複数形を採用すると手間を省けます。

 schema: {
    ...schema,
    user: schema.users,
},

OAuth認証の実装

続いてOAuth認証の実装をしていきます。
今回は、GitHub認証を使用するので、事前にGitHub Applicationの作成が必要です。

以下のページでGitHub Applicationを作成し、各種設定をした後、Better Authの設定を行います。

https://github.com/settings/developers

具体的な手順はドキュメントどおりとなりますので、実装する方はドキュメントを参照してください。

https://www.better-auth.com/docs/authentication/github

GitHub認証の設定

まずは取得したCLIENT_IDCLIENT_SECRETを環境変数に設定します。

.env
GITHUB_CLIENT_ID = your-github-client-id
GITHUB_CLIENT_SECRET = your-github-client-secret
env.ts
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
  server: {
    BETTER_AUTH_SECRET: z.string(),
    BETTER_AUTH_URL: z.string().url(),
    TURSO_AUTH_TOKEN: z.string(),
    TURSO_DATABASE_URL: z.string().url(),
+    GITHUB_CLIENT_ID: z.string(),
+    GITHUB_CLIENT_SECRET: z.string(),
  },
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
  },
  runtimeEnv: {
    BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
    BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
    TURSO_AUTH_TOKEN: process.env.TURSO_AUTH_TOKEN,
    TURSO_DATABASE_URL: process.env.TURSO_DATABASE_URL,
+    GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
+    GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
  },
})

環境変数の設定が完了したら、以下のようにsocialProvidersgithubの設定を追加します。
もちろん、Googleなど他のProviderでも実装できるので、追加実装を試したい方は試してみても良いと思います。

lib/auth/auth.ts
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { nextCookies } from 'better-auth/next-js'
import { twoFactor } from 'better-auth/plugins'
import { passkey } from 'better-auth/plugins/passkey'
+ import { env } from '~/env'
import { sendTwoFactorTokenEmail } from '~/lib/auth/mail'
import { db } from '~/lib/db/db'
import * as schema from '~/lib/db/schema'

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: 'sqlite',
    schema: schema,
    usePlural: true,
  }),
  emailAndPassword: {
    enabled: true,
  },
+  socialProviders: {
+    github: {
+      clientId: env.GITHUB_CLIENT_ID,
+      clientSecret: env.GITHUB_CLIENT_SECRET,
+    },
+  },
  plugins: [
    twoFactor(),
    passkey(),
    nextCookies(),
  ],
})

これでOAuthを実装する準備が整いました。
それでは次に実際のUIを構築し、実装をしていきます。

SignUp・SignIn画面の作成

今回は、実際に処理を行っているForm周りに絞って解説します。
なので、細かい部分を知りたい方はソースコードを参照していただければと思います。

https://github.com/sc30gsw/next15-better-auth-tutorial/tree/main/src/app/(auth)

まずは、各Formの全体のTSXを記載します。

SignUpForm

sign-up-form.tsx
sign-up-form.tsx
'use client'

import { getFormProps, getInputProps } from '@conform-to/react'
import { IconBrandGithub, IconTriangleExclamation } from 'justd-icons'
import type { ReactNode } from 'react'
import { Button, Card, Form, Loader, TextField } from '~/components/justd/ui'
import { useSignUp } from '~/feature/auth/hooks/use-sign-up'
import { authClient } from '~/lib/auth/auth-client'

export function SignUpForm({
  children,
  haveAccountArea,
}: { children: ReactNode; haveAccountArea: ReactNode }) {
  const {
    form,
    fields,
    action,
    getError,
    isPending,
    isSignInPending,
    isAnyPending,
    startTransition,
  } = useSignUp()

  return (
    <Card className="mx-auto w-full max-w-md">
      {children}
      <Form
        {...getFormProps(form)}
        action={action}
        className="flex flex-col items-center justify-center w-full"
      >
        <Card.Content className="space-y-6 w-full">
          {getError() && (
            <div className="bg-danger/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-danger mb-6">
              <IconTriangleExclamation className="size-4" />
              <p>{getError()}</p>
            </div>
          )}
          <div>
            <TextField
              {...getInputProps(fields.name, { type: 'text' })}
              placeholder="Name"
              isDisabled={isAnyPending}
              errorMessage={''}
            />
            <span id={fields.name.errorId} className="text-sm text-red-500">
              {fields.name.errors}
            </span>
          </div>
          <div>
            <TextField
              {...getInputProps(fields.email, { type: 'email' })}
              placeholder="Email"
              isDisabled={isAnyPending}
              errorMessage={''}
            />
            <span id={fields.email.errorId} className="text-sm text-red-500">
              {fields.email.errors}
            </span>
          </div>
          <div>
            <TextField
              {...getInputProps(fields.password, { type: 'password' })}
              placeholder="Password"
              isDisabled={isAnyPending}
              errorMessage={''}
            />
            <span id={fields.password.errorId} className="text-sm text-red-500">
              {fields.password.errors}
            </span>
          </div>
        </Card.Content>
        <Card.Footer className="flex flex-col items-start gap-y-4 w-full">
          <Button
            type="submit"
            className="w-full relative"
            isDisabled={isAnyPending}
          >
            Sign Up
            {isPending && <Loader className="absolute top-3 right-2" />}
          </Button>
          <Button
            intent="secondary"
            className="w-full relative"
            isDisabled={isAnyPending}
            onPress={() => {
              startTransition(async () => {
                const data = await authClient.signIn.social({
                  provider: 'github',
                  callbackURL: '/',
                })
              })
            }}
          >
            <IconBrandGithub />
            Sign In with GitHub
            {isSignInPending && <Loader className="absolute top-3 right-2" />}
          </Button>
          {haveAccountArea}
        </Card.Footer>
      </Form>
    </Card>
  )
}

SignInForm

こちらが、SignInを行うFormです。

sign-in-form.tsx
sign-in-form.tsx
'use client'

import { getFormProps, getInputProps } from '@conform-to/react'
import { IconBrandGithub, IconKey, IconTriangleExclamation } from 'justd-icons'
import type { ReactNode } from 'react'
import { toast } from 'sonner'
import { Button, Card, Form, Loader, TextField } from '~/components/justd/ui'
import { useSignIn } from '~/feature/auth/hooks/use-sign-in'

import { authClient } from '~/lib/auth/auth-client'

export function SignInForm({
  children,
  notHaveAccountArea,
}: { children: ReactNode; notHaveAccountArea: ReactNode }) {
  const {
    form,
    fields,
    action,
    getError,
    isPending,
    isOauthSignInPending,
    isPasskeyPending,
    startPassKyeTransition,
    startTransition,
    isAnyPending,
    router,
  } = useSignIn()

  return (
    <Card className="mx-auto w-full max-w-md">
      {children}
      <Form
        {...getFormProps(form)}
        action={action}
        className="flex flex-col items-center justify-center w-full"
      >
        <Card.Content className="space-y-6 w-full">
          {getError() && (
            <div className="bg-danger/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-danger mb-6">
              <IconTriangleExclamation className="size-4" />
              <p>{getError()}</p>
            </div>
          )}
          <div>
            <TextField
              {...getInputProps(fields.email, { type: 'email' })}
              placeholder="Email"
              isDisabled={isAnyPending}
              errorMessage={''}
            />
            <span id={fields.email.errorId} className="text-sm text-red-500">
              {fields.email.errors}
            </span>
          </div>
          <div>
            <TextField
              {...getInputProps(fields.password, { type: 'password' })}
              placeholder="Password"
              isDisabled={isAnyPending}
              errorMessage={''}
            />
            <span id={fields.password.errorId} className="text-sm text-red-500">
              {fields.password.errors}
            </span>
          </div>
        </Card.Content>
        <Card.Footer className="flex flex-col items-start gap-y-4 w-full">
          <Button
            type="submit"
            className="w-full relative"
            isDisabled={isAnyPending}
          >
            Sign In
            {isPending && <Loader className="absolute top-3 right-2" />}
          </Button>
          <Button
            intent="secondary"
            className="w-full relative"
            isDisabled={isAnyPending}
            onPress={() => {
              startTransition(async () => {
                await authClient.signIn.social({
                  provider: 'github',
                  callbackURL: '/',
                })
              })
            }}
          >
            <IconBrandGithub />
            Sign In with GitHub
            {isOauthSignInPending && (
              <Loader className="absolute top-3 right-2" />
            )}
          </Button>
          <Button
            isDisabled={isAnyPending}
            onPress={() => {
              startPassKyeTransition(async () => {
                const data = await authClient.signIn.passkey()

                if (data?.error) {
                  toast.error('Failed to sign in with passkey')

                  return
                }

                toast.success('Sign in successful')

                router.push('/')
              })
            }}
            className="w-full"
          >
            <IconKey />
            Sign In with Passkey
            {isPasskeyPending && <Loader className="absolute top-3 right-2" />}
          </Button>
          {notHaveAccountArea}
        </Card.Footer>
      </Form>
    </Card>
  )
}

GitHub認証の処理

そして、GitHub認証をしている部分は以下です。

<Button
  intent="secondary"
  className="w-full relative"
  isDisabled={isAnyPending}
  onPress={() => {
    startTransition(async () => {
      await authClient.signIn.social({
        provider: 'github',
        callbackURL: '/',
      })
    })
  }}
>
  <IconBrandGithub />
  Sign In with GitHub
  {isSignInPending && <Loader className="absolute top-3 right-2" />}
</Button>

Better AuthのAPI ClientにsignIn.social()というAPIが定義されているので、これを呼び出し、設定したproviderを指定するだけで実現できます。
これでけで、GitHubのOAuth認証の実装は完了です。

await authClient.signIn.social({
  provider: 'github',
  callbackURL: '/',
})

次にユーザー新規登録時の実装について解説します。

ユーザーSignUp認証

ユーザーのsignUp処理はBetter Authの場合、signUpと同時に認証まで済ませてくれます。
Auth.jsの場合、DBにuserをinsert後、Auth.jsのsignIn()をする必要がありました。
(このあたりは良し悪しありますが、最近では登録と同時に、認証も済ませることが一般的になっているので、Better Authはこのあたりも考慮されていると言えそうです)

では、実際の実装ですが、signUpFormactionで行っています。
処理の中身は以下のようになっております。

sign-up-action.ts
sign-up-action.ts
'use server'

import { parseWithZod } from '@conform-to/zod'
import { APIError } from 'better-auth/api'
import { signUpInputSchema } from '~/feature/auth/types/schemas/sign-up-input-schema'
import { auth } from '~/lib/auth/auth'
import { db } from '~/lib/db/db'

export const signUpAction = async (_: unknown, formData: FormData) => {
  const submission = parseWithZod(formData, { schema: signUpInputSchema })

  if (submission.status !== 'success') {
    return submission.reply()
  }

  try {
    const existingUser = await db.query.users.findFirst({
      where: (users, { eq }) => eq(users.email, submission.value.email),
    })

    if (existingUser) {
      return submission.reply({
        fieldErrors: { message: ['Email or Name already in use'] },
      })
    }

    await auth.api.signUpEmail({
      body: {
        name: submission.value.name,
        email: submission.value.email,
        password: submission.value.password,
      },
    })

    return submission.reply()
  } catch (err) {
    if (err instanceof APIError) {
      return submission.reply({
        fieldErrors: { message: [err.body.message ?? 'Something went wrong'] },
      })
    }

    return submission.reply({
      fieldErrors: { message: ['Something went wrong'] },
    })
  }
}

重要な部分のみ抜粋しますが、この部分になります。

await auth.api.signUpEmail({
  body: {
    name: submission.value.name,
    email: submission.value.email,
    password: submission.value.password,
  },
})

server actionsでの実装なので、server側、つまりauthClientでないAPIを使用する必要があります。
そのため、auth.api.signUpEmail()としています。
これだけで、必要なテーブルへの登録から認証まで済ませることができます。

そして、認証が済み次第、Cookieにトークンが保存されます。
https://www.better-auth.com/docs/integrations/next#server-action-cookies

また、Client側のactionのハンドリングでは、signUpEmail()が成功すると、2FA用の設定をするための画面に遷移します。
(2FAの実装については後述します)

const [lastResult, action, isPending] = useActionState<
  Awaited<ReturnType<typeof signUpAction>> | null,
  FormData
>(async (prev, formData) => {
  const result = await signUpAction(prev, formData)
  if (result.status === 'success') {
    toast.success('Sign up successful')
    router.push('/two-factor')
  }

  return result
}, null)

以上が、ユーザーsignUp認証の実装です。
次に、通常のemail・password signInについての実装を解説します。

Email・Password 認証

Email・Passwordの認証もserver actionsで実装を行います。
公式ドキュメントにもあるようにauth.api.signInEmail()を使用していきます。

https://www.better-auth.com/docs/basic-usage#server-side-authentication

まずは、signInのserver actionsの全体像です。

sign-in-action.ts
sign-in-action.ts
'use server'

import { parseWithZod } from '@conform-to/zod'
import { APIError } from 'better-auth/api'
import { redirect } from 'next/navigation'
import { signInInputSchema } from '~/feature/auth/types/schemas/sign-in-input-schema'
import { auth } from '~/lib/auth/auth'
import { db } from '~/lib/db/db'

export const signInAction = async (_: unknown, formData: FormData) => {
  const submission = parseWithZod(formData, { schema: signInInputSchema })

  if (submission.status !== 'success') {
    return submission.reply()
  }

  try {
    const user = await db.query.users.findFirst({
      with: {
        twoFactors: true,
        accounts: true,
      },
      where: (users, { eq }) => eq(users.email, submission.value.email),
    })

    if (user?.accounts[0].providerId !== 'credential') {
      return submission.reply({
        fieldErrors: { message: ['Please sign in with Oauth'] },
      })
    }

    if (!user?.twoFactorEnabled || user.twoFactors.length === 0) {
      await auth.api.signInEmail({
        body: {
          email: submission.value.email,
          password: submission.value.password,
        },
      })

      redirect('/two-factor')
    }

    const res = await auth.api.signInEmail({
      body: {
        email: submission.value.email,
        password: submission.value.password,
        asResponse: true,
      },
    })

    if ('twoFactorRedirect' in res) {
      return submission.reply()
    }

    redirect('/two-factor')
  } catch (err) {
    if (err instanceof APIError) {
      return submission.reply({
        fieldErrors: { message: [err.body.message ?? 'Something went wrong'] },
      })
    }

    if (err instanceof Error) {
      if (err.message === 'NEXT_REDIRECT') {
        redirect('/two-factor')
      }
    }

    return submission.reply({
      fieldErrors: { message: ['Something went wrong'] },
    })
  }
}

こちらも重要な部分を抜粋して解説します。

まずは、userをDBから取得してきます。

この際、OAuth認証のユーザーの場合、OAuthボタンから認証してほしいため、エラーとしています。
(このエラーの場合、前述したauthClientのOAuthの処理を行うでも良いと思います。その場合、Client側のuseActionStateでのハンドリングを追加する必要があります)

そして、2FAが有効でなく、twoFactorsテーブルにデータがない場合に、auth.api.signInEmailとして2FAではない通常のEmail・Passwordの認証をするようにしています。

そして、認証が成功したら、2FAの設定を促すため、2FA認証設定の画面に遷移するようにしています。

const user = await db.query.users.findFirst({
  with: {
    twoFactors: true,
    accounts: true,
  },
  where: (users, { eq }) => eq(users.email, submission.value.email),
})

if (user?.accounts[0].providerId !== 'credential') {
  return submission.reply({
    fieldErrors: { message: ['Please sign in with Oauth'] },
  })
}

if (!user?.twoFactorEnabled || user.twoFactors.length === 0) {
  await auth.api.signInEmail({
    body: {
      email: submission.value.email,
      password: submission.value.password,
    },
  })

  redirect('/two-factor')
}

これでEmail・Password認証の実装は完了です。
続いて、2FA認証の実装の解説をします。

2FA(2要素)認証の実装

2FA認証ですが、今回はResendを用いた、メール送信されるコードを用いて、実装していきたいと思います。

Resendの導入

まず、resend@react-email/componentsを導入します。

fish
bun add resend @react-email/components

React EmailはReactのコンポーネントのようにメールのスタイリングを行うことができるライブラリです。

https://react.email/

Resendライブラリを導入したら、API Keyの発行とセットアップを行います。

Resendのセットアップ

まずは、以下の手順にある通り、API Keyを発行します。

https://resend.com/docs/send-with-nextjs

発行できたら、環境変数に追加します。

.env
RESEND_API_KEY = your-resend-api-key
env.ts
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
  server: {
    BETTER_AUTH_SECRET: z.string(),
    BETTER_AUTH_URL: z.string().url(),
    TURSO_AUTH_TOKEN: z.string(),
    TURSO_DATABASE_URL: z.string().url(),
    GITHUB_CLIENT_ID: z.string(),
    GITHUB_CLIENT_SECRET: z.string(),
+    RESEND_API_KEY: z.string(),
  },
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
  },
  runtimeEnv: {
    BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
    BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
    TURSO_AUTH_TOKEN: process.env.TURSO_AUTH_TOKEN,
    TURSO_DATABASE_URL: process.env.TURSO_DATABASE_URL,
    GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
    GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
+    RESEND_API_KEY: process.env.RESEND_API_KEY,
  },
})

次に、メール送信の設定を行います。

メール送信設定

以下が、メール送信の設定で、reactというプロパティに指定したコンポーネントがメール本文として表示されます。

lib/auth/mail.ts
import { Resend } from 'resend'
import { env } from '~/env'
import { OTPNotificationEmail } from '~/feature/auth/components/otp-notification-email'

export const resend = new Resend(env.RESEND_API_KEY)

export const sendTwoFactorTokenEmail = async (email: string, token: string) => {
  await resend.emails.send({
    from: 'onboarding@resend.dev',
    to: email,
    subject: '2段階認証',
    react: OTPNotificationEmail({ email, otpCode: token }),
  })
}

以下が、メール本文のコンポーネントです。

otp-notification-email.tsx
otp-notification-email.tsx
import {
  Body,
  Container,
  Head,
  Heading,
  Hr,
  Html,
  Preview,
  Tailwind,
  Text,
} from '@react-email/components'
import { tv } from 'tailwind-variants'

const otpNotificationEmailStyles = tv({
  slots: {
    body: 'm-auto bg-white font-sans',
    container:
      'mx-auto my-10 max-w-[480px] rounded border border-solid border-gray-200 px-10 py-5',
    heading: 'mx-0 my-7 p-0 text-center',
    content: 'ml-1 leading-4',
    code: '',
  },
  compoundSlots: [{ slots: ['heading', 'content'], class: 'text-black' }],
  variants: {
    color: {
      primary: { code: 'text-blue-600' },
    },
    size: {
      sm: { content: 'text-sm' },
      md: { code: 'font-medium' },
      lg: { heading: 'text-xl font-semibold' },
    },
  },
})

type OtpNotificationEmailProps = {
  email: string
  otpCode: string
}

export function OTPNotificationEmail({
  email,
  otpCode,
}: OtpNotificationEmailProps) {
  const { body, container, heading, content, code } =
    otpNotificationEmailStyles()

  return (
    <Html>
      <Head />
      <Preview>OTP確認メール</Preview>
      <Tailwind>
        <Body className={body()}>
          <Container className={container()}>
            <Heading className={heading({ size: 'lg' })}>OTP確認メール</Heading>
            <Text className={content({ size: 'sm' })}>
              あなたのOTPコードは
              <span className={code({ color: 'primary', size: 'md' })}>
                {otpCode}
              </span>
              です。このコードを使用して認証を完了してください。
            </Text>
            <EmailFooter email={email} />
          </Container>
        </Body>
      </Tailwind>
    </Html>
  )
}

const emailFooterStyles = tv({
  slots: {
    hrBorder: 'mx-0 my-6 w-full border border-gray-200',
    text: 'text-[12px] leading-6 text-gray-500',
    emailText: 'text-black',
  },
})

type EmailFooterProps = {
  email: string
}

export function EmailFooter({ email }: EmailFooterProps) {
  const { hrBorder, text, emailText } = emailFooterStyles()

  return (
    <Tailwind>
      <Hr className={hrBorder()} />
      <Text className={text()}>
        このメールは<span className={emailText()}>{email}</span>
        宛に送信されました。このメールに心当たりがない場合は、お手数ですがこのまま削除してください。
      </Text>
    </Tailwind>
  )
}

実際の表示は以下のようになります。
2fa codes for email

続いて、Better Auth側の設定を行います。

Better Authの設定

以下のようにsendOTPを使用することで、ランダムなコードが自動生成され、Resendで設定したメールとしてユーザーのメールアドレスに送信されるという仕組みです。

lib/auth/auth.ts
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { nextCookies } from 'better-auth/next-js'
import { twoFactor } from 'better-auth/plugins'
import { passkey } from 'better-auth/plugins/passkey'
import { env } from '~/env'
+ import { sendTwoFactorTokenEmail } from '~/lib/auth/mail'
import { db } from '~/lib/db/db'
import * as schema from '~/lib/db/schema'

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: 'sqlite',
    schema: schema,
    usePlural: true,
  }),
  emailAndPassword: {
    enabled: true,
  },
  socialProviders: {
    github: {
      clientId: env.GITHUB_CLIENT_ID,
      clientSecret: env.GITHUB_CLIENT_SECRET,
    },
  },
  plugins: [
    twoFactor({
+      otpOptions: {
+        async sendOTP({ user, otp }) {
+          await sendTwoFactorTokenEmail(user.email, otp)
+        },
+      },
+    }),
    passkey(),
    nextCookies(),
  ],
})

https://www.better-auth.com/docs/plugins/2fa#otp

2FAのUI実装

2FAは2要素認証が有効なユーザーのみ行ったり、有効化ユーザーの場合、有効化処理をスキップし、2要素認証でのsignInをさせるなどの分岐が必要です。
そのため、この実装はpage.tsxから解説します。

まずは全体像です。

page.tsx
app/two-factor/page.tsx
import type { InferResponseType } from 'hono'
import type { SearchParams } from 'nuqs'
import React from 'react'
import { Card } from '~/components/justd/ui'
import { OtpForm } from '~/feature/auth/components/otp-form'
import { PassKeyContainer } from '~/feature/auth/components/pass-key-container'

import { TwoFactorForm } from '~/feature/auth/components/two-factor-form'
import { GET_TWO_FACTOR } from '~/feature/auth/constants/cache-kyes'
import { loadPassKeySearchParams } from '~/feature/auth/types/search-params/pass-key-search-params'

import { getServerSession } from '~/lib/auth/get-server-session'
import { fetcher } from '~/lib/fetcher'
import { client } from '~/lib/rpc'

async function getTwoFactor(currentUserId: string) {
  type ResType = InferResponseType<
    (typeof client.api)['two-factors']['$get'],
    200
  >
  const url = client.api['two-factors'].$url()

  const res = await fetcher<ResType>(url, {
    headers: {
      Authorization: currentUserId,
    },
    cache: 'force-cache',
    next: {
      tags: [GET_TWO_FACTOR],
    },
  })

  return res
}

export default async function TwoFactorPage({
  searchParams,
}: { searchParams: Promise<SearchParams> }) {
  const { isPassKey } = await loadPassKeySearchParams(searchParams)

  if (isPassKey) {
    return <PassKeyContainer />
  }

  const session = await getServerSession()

  if (!session) {
    return <OtpForm />
  }

  const res = await getTwoFactor(session.user.id)

  if (res.twoFactor) {
    return <OtpForm />
  }

  return (
    <TwoFactorForm>
      <Card.Header>
        <Card.Title>Two Factor</Card.Title>
        <Card.Description>
          Input your password and two factor code
        </Card.Description>
      </Card.Header>
    </TwoFactorForm>
  )
}

そして重要な箇所を抜粋したのが、以下となります。
まず、sessionによる分岐ですが、sessionがないということは認証されていないということです。

また、Better Authの仕様上、2要素認証を有効化したユーザーがauth.api.signInEmail()でsignInを試みると、signInされず、2要素認証が必要である旨のレスポンスが返却されます。

そのため、まずはsessionがない場合に、この画面に来た場合にOTP入力のFormを表示します。

次にAPIのレスポンスでの判定ですが、この分岐は1番最初のsignIn時に通る分岐です。
つまり、Email・Passwordを入力後、2要素認証の有効化処理を行った後に入る分岐ということになります。
このときには、sessionが取得でき、twoFactorsテーブルにもログインユーザーのtwoFactor情報があるため、この分岐となります。

ちなみに、2要素認証有効化の処理はTwoFactorFormにて行っております。
つまり、1番最初のsignIn時には、まずTwoFactorFormが表示され、2要素認証を有効化したら、res.twoFactorの分岐に入るという処理になってます。

そして、2回目以降のsignInの際には、!sessionの分岐に入り、2要素認証によるsignIn処理を行うという流れです。

const session = await getServerSession()

if (!session) {
    return <OtpForm />
}

const res = await getTwoFactor(session.user.id)

if (res.twoFactor) {
    return <OtpForm />
}

return (
    <TwoFactorForm>
      <Card.Header>
        <Card.Title>Two Factor</Card.Title>
        <Card.Description>Input your password and two factor code</Card.Description>
      </Card.Header>
    </TwoFactorForm>
)

つまり、今回はsignIn画面との密に繋がっているということです。
そのため、全体の流れを追うにはsignInから追う必要があります。

signIn処理から2FAの流れ

まずは、signInの処理から見ていきます。

sign-in-action.ts
sign-in-action.ts
'use server'

import { parseWithZod } from '@conform-to/zod'
import { APIError } from 'better-auth/api'
import { redirect } from 'next/navigation'
import { signInInputSchema } from '~/feature/auth/types/schemas/sign-in-input-schema'
import { auth } from '~/lib/auth/auth'
import { db } from '~/lib/db/db'

export const signInAction = async (_: unknown, formData: FormData) => {
  const submission = parseWithZod(formData, { schema: signInInputSchema })

  if (submission.status !== 'success') {
    return submission.reply()
  }

  try {
    const user = await db.query.users.findFirst({
      with: {
        twoFactors: true,
        accounts: true,
      },
      where: (users, { eq }) => eq(users.email, submission.value.email),
    })

    if (user?.accounts[0].providerId !== 'credential') {
      return submission.reply({
        fieldErrors: { message: ['Please sign in with Oauth'] },
      })
    }

    if (!user?.twoFactorEnabled || user.twoFactors.length === 0) {
      await auth.api.signInEmail({
        body: {
          email: submission.value.email,
          password: submission.value.password,
        },
      })

      redirect('/two-factor')
    }

    const res = await auth.api.signInEmail({
      body: {
        email: submission.value.email,
        password: submission.value.password,
        asResponse: true,
      },
    })

    if ('twoFactorRedirect' in res) {
      return submission.reply()
    }

    redirect('/two-factor')
  } catch (err) {
    if (err instanceof APIError) {
      return submission.reply({
        fieldErrors: { message: [err.body.message ?? 'Something went wrong'] },
      })
    }

    if (err instanceof Error) {
      if (err.message === 'NEXT_REDIRECT') {
        redirect('/two-factor')
      }
    }

    return submission.reply({
      fieldErrors: { message: ['Something went wrong'] },
    })
  }
}

重要なのは以下の部分です。
2要素認証が有効化されている場合、条件分岐に入ります(Client側のactionハンドリング処理に移ることを意味します)。

それ以外は初回signInなので、通常通り画面遷移させます。

const res = await auth.api.signInEmail({
  body: {
    email: submission.value.email,
    password: submission.value.password,
    asResponse: true,
  },
})

// 2回目以降のsignInもしくは、2要素認証を有効化している場合ではこの分岐に入る
if ('twoFactorRedirect' in res) {
  return submission.reply()
}

redirect('/two-factor')

そしてClient側ですが、全体像は以下です。

use-sign-in.ts
use-sign-in.ts
const [lastResult, action, isPending] = useActionState<
Awaited<ReturnType<typeof signInAction> | null>,
FormData
>(async (prev, formData) => {
const actionResult = await signInAction(prev, formData)

if (actionResult.status === 'error') {
  if (actionResult?.error && Array.isArray(actionResult.error.message)) {
    toast.error(actionResult.error.message.join(', '))
    return null
  }
  toast.error('Failed to sign in')
  return actionResult
}

const result = await authClient.twoFactor.sendOtp()

if (result.error) {
  toast.error('Failed to send OTP')

  return null
}

toast.success('OTP sent to your email')
router.push('/two-factor')

return null
}, null)

重要なのは、以下の部分で、server actionsで実行されるsignInEmail()後にsubmissionを返却しますが、errorなどがない場合には、sendOtp()で2要素認証のコードをメール送信します。

その後two-factors/page.tsxに遷移する仕組みです。

補足すると、初回認証の場合は、server actions側のredirect()で画面遷移するため、sendOtp()が実行されるのは2回目以降のsignInの時となります。

const result = await authClient.twoFactor.sendOtp()

if (result.error) {
  toast.error('Failed to send OTP')

  return null
}

toast.success('OTP sent to your email')
router.push('/two-factor')

そして、two-factor画面では、初回signInなら、TwoFactorFormを表示し、2回目以降あるいはtwoFactor有効済みの場合はOTP入力のFormを表示するということを実現しています。

two-factor/page.tsx
const session = await getServerSession()

if (!session) {
    return <OtpForm />
}

const res = await getTwoFactor(session.user.id)

if (res.twoFactor) {
    return <OtpForm />
}

return (
    <TwoFactorForm>
      <Card.Header>
        <Card.Title>Two Factor</Card.Title>
        <Card.Description>Input your password and two factor code</Card.Description>
      </Card.Header>
    </TwoFactorForm>
)

以上が、signInから2FA画面の処理の流れです。
次に実際、2FA有効化や2FA認証の実態を見ていきます。

2FA有効化(初回signIn時)の実装

まずは2FA有効化の処理です。

UIの全体像は以下ですが、実際に重要なのはuseTwoFactor()useActionStateで行っているserver actionsのハンドリングの処理です。

two-factor-form.tsx
two-factor-form.tsx
'use client'

import { getFormProps, getInputProps } from '@conform-to/react'
import { IconKey } from 'justd-icons'
import type { ReactNode } from 'react'
import { Button, Card, Form, Loader, TextField } from '~/components/justd/ui'

import { useTwoFactor } from '~/feature/auth/hooks/use-two-factor'

export function TwoFactorForm({ children }: { children: ReactNode }) {
  const { form, fields, action, isPending } = useTwoFactor()

  return (
    <Card className="mx-auto w-full max-w-md">
      {children}
      <Card.Content className="space-y-6 w-full">
        <Form
          {...getFormProps(form)}
          action={action}
          className="flex flex-col items-center justify-center w-full"
        >
          <div className="w-full">
            <TextField
              {...getInputProps(fields.password, { type: 'password' })}
              placeholder="Password"
              isDisabled={isPending}
              errorMessage={''}
            />
            <span id={fields.password.errorId} className="text-sm text-red-500">
              {fields.password.errors}
            </span>
          </div>
        </Form>
      </Card.Content>
      <Card.Footer className="flex flex-col items-start gap-y-4 w-full">
        <Button
          type="submit"
          className="w-full relative"
          isDisabled={isPending}
        >
          <IconKey />
          Enable Two Factor
          {isPending && <Loader className="absolute top-3 right-2" />}
        </Button>
      </Card.Footer>
    </Card>
  )
}

以下が、Client側のserver actionのハンドリング処理です。

まずはauthClient.twoFactor.enable()ですが、これで2要素認証を有効化(twoFactors テーブルに登録)を行います。

次いで、sentOtp()による2要素認証のコード送信を行います。

最後に、これらの処理でエラーがない場合updateUserForWtoFactorEnable()というserver actionsでusersテーブルのtwoFactorEnabledカラムtrueにするよう更新します。
(この処理だけは、Better Auth側で行ってくれないので注意が必要です)

この処理がないと、2FA有効化と判定されず、2要素認証の処理の途中のどこかでエラーとなります。

use-two-factor.ts
const [lastResult, action, isPending] = useActionState<
Awaited<ReturnType<typeof twoFactorEnableAction> | null>,
FormData
>(async (prev, formData) => {
const result = await twoFactorEnableAction(prev, formData)

if (result.status === 'success') {
  const { error: twoFactorEnableError } = await authClient.twoFactor.enable(
    {
      password: result.initialValue?.password.toString() ?? '',
    },
  )

  if (twoFactorEnableError) {
    toast.error('Something went wrong')

    return null
  }

  const { error: sendOtpError } = await authClient.twoFactor.sendOtp()

  if (sendOtpError) {
    toast.error('Something went wrong')

    return null
  }

  const { isSuccess } = await updateUserForTwoFactorEnable()

  if (!isSuccess) {
    toast.error('Something went wrong')
    return null
  }

  toast.success('Send your OTP to your email')
  router.refresh()

  return null
}

return result
}, null)

以下が、updateUserForTwoFactorEnableの内容です。

updateUserForTwoFactorEnable
update-user-for-two-factor-enable.ts
'use server'

import { eq } from 'drizzle-orm'
import { revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import {
  GET_TWO_FACTOR,
  GET_USER_DETAILS,
} from '~/feature/auth/constants/cache-kyes'
import { getServerSession } from '~/lib/auth/get-server-session'
import { db } from '~/lib/db/db'
import { users } from '~/lib/db/schema'

export const updateUserForTwoFactorEnable = async () => {
  try {
    const session = await getServerSession()

    if (!session) {
      redirect('/sign-in')
    }

    await db
      .update(users)
      .set({ twoFactorEnabled: true })
      .where(eq(users.id, session.user.id))

    revalidateTag(GET_TWO_FACTOR)
    revalidateTag(GET_USER_DETAILS)

    return { isSuccess: true }
  } catch (err) {
    return { isSuccess: false }
  }
}

そして再度、twoFactorの画面に戻ってきたときには、OtpFormが表示されます。
これでようやく、2要素認証の認証処理を行う準備が整ったというわけです。

OTP入力による2要素認証の実装

まずはUIの全体像です。
重要なのは、やはりuseVerifyOtp()というカスタムフックの処理です。

otp-form.tsx
otp-form.tsx
'use client'

import { IconMail } from 'justd-icons'
import Link from 'next/link'

import { toast } from 'sonner'
import { Button, InputOTP, Loader } from '~/components/justd/ui'
import { useVerifyOtp } from '~/feature/auth/hooks/use-verify-otp'

import { authClient } from '~/lib/auth/auth-client'

export function OtpForm() {
  const {
    value,
    setValue,
    isShow,
    setSearchCondition,
    isPending,
    startTransition,
    verifyOtp,
  } = useVerifyOtp()

  if (isShow) {
    return (
      <div className="flex flex-col items-center gap-y-4">
        <Button onPress={() => setSearchCondition({ isPassKey: true })}>
          Add Passkey
        </Button>
        <Link href={'/'}>Skip</Link>
      </div>
    )
  }

  return (
    <div className="space-y-2">
      <InputOTP
        maxLength={6}
        disabled={isPending}
        value={value}
        onChange={(e) => {
          setValue(e)

          if (e.length === 6) {
            // 6文字目が表示されるまで待機する
            setTimeout(() => {
              startTransition(async () => {
                await verifyOtp(e)
              })
            }, 500)

            return
          }
        }}
      >
        <InputOTP.Group>
          {[...Array(6)].map((_, index) => (
            <InputOTP.Slot key={crypto.randomUUID()} index={index} />
          ))}
        </InputOTP.Group>
      </InputOTP>

      <div className="flex justify-end text-primary hover:text-primary/80">
        <Button
          appearance="outline"
          size="small"
          className="relative w-52"
          isDisabled={isPending}
          onPress={async () =>
            startTransition(async () => {
              await authClient.twoFactor.sendOtp()
              setValue('')
              toast.success('Send your OTP to your email')
            })
          }
        >
          <IconMail />
          メールを再送する
          {isPending && <Loader className="absolute top-3 right-2" />}
        </Button>
      </div>
    </div>
  )
}

ということで、カスタムフックについても全体像を以下に記載します。
このカスタムフックで特に重要なのはauthClient.twoFactor.verifyOtp()です。

use-verify-otp.ts
use-verify-otp.ts
import { useRouter } from 'next/navigation'
import { parseAsBoolean, useQueryStates } from 'nuqs'
import { useState, useTransition } from 'react'
import { toast } from 'sonner'
import { authClient } from '~/lib/auth/auth-client'

export function useVerifyOtp() {
  const [value, setValue] = useState('')
  const [isShow, setIsShow] = useState(false)

  const [isPending, startTransition] = useTransition()

  const [_, setSearchCondition] = useQueryStates(
    { isPassKey: parseAsBoolean.withDefault(false) },
    {
      shallow: false,
      history: 'push',
    },
  )

  const router = useRouter()

  const verifyOtp = async (code: string) => {
    await authClient.twoFactor.verifyOtp(
      { code },
      {
        onSuccess() {
          toast.success('Verification successful', {
            description: 'Do you want to add passkey?',
            action: {
              label: "Let's add",
              onClick: () => {
                setSearchCondition({ isPassKey: true })
              },
            },
            cancel: {
              label: 'No thanks',
              onClick: () => {
                setSearchCondition({ isPassKey: false })
                router.push('/')
              },
            },
          })

          setValue('')
          setIsShow(true)
        },
        onError(ctx) {
          toast.error(ctx.error.message)
          setValue('')
        },
      },
    )
  }

  return {
    value,
    setValue,
    isShow,
    setSearchCondition,
    isPending,
    startTransition,
    verifyOtp,
  }
}

https://www.better-auth.com/docs/plugins/2fa#verifying-otp

このAPIを使用して入力されたcodeを受け取ることで、自動でメール送信されたcodeとの整合性を判定してくれます。

また、認証の成否のハンドリングをonSuccess()onError()というコールバックで行うことができます。

以上が2要素認証の実装です。

いよいよ、認証処理の最後の実装のパスキー認証を実装していきます。

パスキー認証の実装

パスキーの認証ですが、以下を参照して実装していきます。

https://www.better-auth.com/docs/plugins/passkey

パスキーの登録

まずは、パスキーの登録を行う必要があります。
パスキーの登録には、authClient.passkey.addPasskey()を使用する必要があります。

以下のように、パスキー登録用のボタンをクリックした際に、APIを関数呼び出しするだけで登録ができます。
以降の登録処理は、ブラウザの指示に従うだけでパスキーを登録することができます。

passkye-card.tsx
'use client'

import { IconCirclePlus } from 'justd-icons'
import { useRouter } from 'next/navigation'
import React, { type ReactNode } from 'react'
import { toast } from 'sonner'
import { Card } from '~/components/justd/ui'
import { passKeyAction } from '~/feature/auth/actions/pass-key-action'
import { PasskeyButton } from '~/feature/auth/components/passkey-button'
import { authClient } from '~/lib/auth/auth-client'

export function PasskeyCard({
  children,
  linkArea,
}: { children: ReactNode; linkArea: ReactNode }) {
  const router = useRouter()

  return (
    <Card className="mx-auto w-full max-w-md">
      {children}
      <Card.Content className="space-y-6 w-full">
        <PasskeyButton
          icon={<IconCirclePlus />}
          label="Add Passkey"
          onClick={async () => {
            const data = await authClient.passkey.addPasskey()

            if (data?.error) {
              toast.error('Failed to add passkey')

              return
            }

            toast.success('Passkey added successfully')

            passKeyAction()
          }}
        />
      </Card.Content>
      <Card.Footer className="flex flex-col items-start gap-y-2 w-full">
        {linkArea}
      </Card.Footer>
    </Card>
  )
}

パスキー認証

続いてパスキーの認証です。
これはsign-in-formに実装してあります。

sign-in-form.tsx
sign-in-form.tsx
'use client'

import { getFormProps, getInputProps } from '@conform-to/react'
import { IconBrandGithub, IconKey, IconTriangleExclamation } from 'justd-icons'
import type { ReactNode } from 'react'
import { toast } from 'sonner'
import { Button, Card, Form, Loader, TextField } from '~/components/justd/ui'
import { useSignIn } from '~/feature/auth/hooks/use-sign-in'

import { authClient } from '~/lib/auth/auth-client'

export function SignInForm({
  children,
  notHaveAccountArea,
}: { children: ReactNode; notHaveAccountArea: ReactNode }) {
  const {
    form,
    fields,
    action,
    getError,
    isPending,
    isOauthSignInPending,
    isPasskeyPending,
    startPassKyeTransition,
    startTransition,
    isAnyPending,
    router,
  } = useSignIn()

  return (
    <Card className="mx-auto w-full max-w-md">
      {children}
      <Form
        {...getFormProps(form)}
        action={action}
        className="flex flex-col items-center justify-center w-full"
      >
        <Card.Content className="space-y-6 w-full">
          {getError() && (
            <div className="bg-danger/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-danger mb-6">
              <IconTriangleExclamation className="size-4" />
              <p>{getError()}</p>
            </div>
          )}
          <div>
            <TextField
              {...getInputProps(fields.email, { type: 'email' })}
              placeholder="Email"
              isDisabled={isAnyPending}
              errorMessage={''}
            />
            <span id={fields.email.errorId} className="text-sm text-red-500">
              {fields.email.errors}
            </span>
          </div>
          <div>
            <TextField
              {...getInputProps(fields.password, { type: 'password' })}
              placeholder="Password"
              isDisabled={isAnyPending}
              errorMessage={''}
            />
            <span id={fields.password.errorId} className="text-sm text-red-500">
              {fields.password.errors}
            </span>
          </div>
        </Card.Content>
        <Card.Footer className="flex flex-col items-start gap-y-4 w-full">
          <Button
            type="submit"
            className="w-full relative"
            isDisabled={isAnyPending}
          >
            Sign In
            {isPending && <Loader className="absolute top-3 right-2" />}
          </Button>
          <Button
            intent="secondary"
            className="w-full relative"
            isDisabled={isAnyPending}
            onPress={() => {
              startTransition(async () => {
                await authClient.signIn.social({
                  provider: 'github',
                  callbackURL: '/',
                })
              })
            }}
          >
            <IconBrandGithub />
            Sign In with GitHub
            {isOauthSignInPending && (
              <Loader className="absolute top-3 right-2" />
            )}
          </Button>
          <Button
            isDisabled={isAnyPending}
            onPress={() => {
              startPassKyeTransition(async () => {
                const data = await authClient.signIn.passkey()

                if (data?.error) {
                  toast.error('Failed to sign in with passkey')

                  return
                }

                toast.success('Sign in successful')

                router.push('/')
              })
            }}
            className="w-full"
          >
            <IconKey />
            Sign In with Passkey
            {isPasskeyPending && <Loader className="absolute top-3 right-2" />}
          </Button>
          {notHaveAccountArea}
        </Card.Footer>
      </Form>
    </Card>
  )
}

以下が該当部分です。
authClient.signIn.passKey()とこちらも関数呼び出しするだけで実装できます。

あとは表示されたパスキーの中から認証に使用するものを選択するだけです。
ここで、間違えたパスキーを選択してもBetter Auth側が自動でエラー判定してくれるため、開発者は関数の呼び出しとハンドリングという本来のアプリケーション開発に集中することができるというわけです。

<Button
  isDisabled={isAnyPending}
  onPress={() => {
    startPassKyeTransition(async () => {
      const data = await authClient.signIn.passkey()

      if (data?.error) {
        toast.error('Failed to sign in with passkey')

        return
      }

      toast.success('Sign in successful')

      router.push('/')
    })
  }}
  className="w-full"
>
  <IconKey />
  Sign In with Passkey
  {isPasskeyPending && <Loader className="absolute top-3 right-2" />}
</Button>

ユーザー情報取得

ユーザー情報はauth.api.getSession()というAPIで取得できます。

動的APIのheaders()を使用すること、また何回も呼び出す関数であるためcache()でラップするのが良いと私は考えております。

lib/auth/get-server-session.ts
import { headers } from 'next/headers'
import { cache } from 'react'
import { auth } from '~/lib/auth/auth'

export const getServerSession = cache(async () => {
  const sessionData = await auth.api.getSession({ headers: await headers() })

  return sessionData
})

この関数では以下のように、sessionテーブルの値とusersテーブルの値を取得できます。

connst sessionData: {
    session: {
        id: string;
        createdAt: Date;
        updatedAt: Date;
        userId: string;
        expiresAt: Date;
        token: string;
        ipAddress?: string | null | undefined;
        userAgent?: string | null | undefined;
    };
    user: {
        id: string;
        email: string;
        emailVerified: boolean;
        name: string;
        createdAt: Date;
        updatedAt: Date;
        image?: string | null | undefined;
        twoFactorEnabled: boolean | null | undefined;
    };
} | null

signOutの実装

最後にsignOutの実装について解説します。
signOutはauthClient.signOutにて行えます。

fetchOptionsというoptionプロパティで成功時・失敗時のハンドリングをすることができます。

sign-out-button.tsx
'use client'

import { IconLogout } from 'justd-icons'
import { useRouter } from 'next/navigation'
import React, { useTransition } from 'react'
import { toast } from 'sonner'
import { Button, Loader } from '~/components/justd/ui'
import { signOutAction } from '~/feature/auth/actions/sign-out-action'
import { authClient } from '~/lib/auth/auth-client'

export function SignOutButton() {
  const [isPending, startTransition] = useTransition()
  const router = useRouter()

  return (
    <Button
      className="w-40 my-2 relative"
      isDisabled={isPending}
      onPress={() =>
        startTransition(async () => {
          await signOutAction()

          await authClient.signOut({
            fetchOptions: {
              onSuccess: () => {
                toast.success('Signed out successfully')
                router.push('/sign-in')
              },
              onError: () => {
                toast.error('Failed to sign out')
                router.push('/')
              },
            },
          })
        })
      }
    >
      Sign Out
      {isPending ? (
        <Loader className="absolute top-3 right-2" />
      ) : (
        <IconLogout className="absolute top-2 right-2" />
      )}
    </Button>
  )
}

以上がBetter Authによる認証アプリケーションの実装となります。

おわりに

ここまで読んでくださり、ありがとうございます!

個人的には、比較的新しいOSSということもあり、UXや開発体験の面で他の認証系のライブラリより進んでいると感じました。

また、最新の認証のパスキーが使えるのがライブラリではBetter Authくらいかなと思うので、その点も魅力かと思います。

最後になりますが、実装の詳細について知りたい方は実際のソースコードをご参照していただければと思います。

https://github.com/sc30gsw/next15-better-auth-tutorial

参考文献

https://www.better-auth.com/

https://orm.drizzle.team/

https://resend.com/docs/api-reference/introduction

https://dev.to/daanish2003/two-factor-authentication-using-betterauth-nextjs-prisma-shadcn-and-resend-1b5p

https://zenn.dev/koduki/articles/061998796e0d33

https://zenn.dev/r1013t/articles/bcfeb72a22f765

https://zenn.dev/eju_labs/articles/2bd20ff92dc801

23

Discussion

ログインするとコメントできます