Zenn
🛂

【Auth.js v5 ✗ Next.js 15】Server Actionsで実装する認証機能

2025/02/08に公開
7

はじめに

今回はAuth.js v5とNext.js 15およびReact19による、Server Actions対応版の認証機能の実装方法について解説したいと思います

技術スタック

最初に、今回使用する技術スタックを紹介します。
以下のようになっているので、認証関連の処理以外の、各セクションの事細かなコードについては、ドキュメントを参照いただければと思います。

認証処理

さっそく実装に入ります。
まずは、今回の実装に必要なライブラリを導入します。

必要なライブラリを導入

以下のコマンドを実行し、必要なライブラリを導入しましょう。

fish
bun add next-auth@beta bcryptjs @conform-to/react @conform-to/zod drizzle-orm @libsql/client @auth/drizzle-adapter @t3-oss/env-nextjs
fish
bun add -D drizzle-kit @types/bcryptjs

続いて、Auth.jsのinstallationにある通り以下のコマンドを実行し、Auth.jsに必要な環境変数を作成していきます。

fish
bunx auth secret

https://authjs.dev/getting-started/installation?framework=next-js

ここまででライブラリの導入は完了しました。

DB接続

以下のDrizzleドキュメントに従い、Tursoの環境を構築していきます。

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

ドキュメントにある手順に従い、コマンドの実行及び、環境変数等の定義をしていきます。
環境変数については、型安全に扱うためのライブラリである@t3-oss/env-nextjsにより、以下のようにzodの型定義をして、このファイルから使用するようにします。

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

export const env = createEnv({
  server: {
    TURSO_AUTH_TOKEN: z.string(),
    TURSO_CONNECTION_URL: z.string().url(),
    AUTH_SECRET: z.string(),
  },
  runtimeEnv: {
    TURSO_CONNECTION_URL: process.env.TURSO_CONNECTION_URL,
    TURSO_AUTH_TOKEN: process.env.TURSO_AUTH_TOKEN,
    AUTH_SECRET: process.env.AUTH_SECRET,
  },
})

次に実際にDBに接続するためのコードを作成します。

db/db.ts
import * as schema from '@/db/schema'
import { env } from '@/env'
import { drizzle } from 'drizzle-orm/libsql'

export const db = drizzle({
  connection: {
    url: env.TURSO_CONNECTION_URL,
    authToken: env.TURSO_AUTH_TOKEN,
  },
  schema: schema,
})

最後に、Drizzleの設定ファイルの記述を行います。
これはプロジェクトのrootに配置します。(src内ではないので注意が必要です)
このファイルには以下の情報が含まれます。

データベース接続、移行フォルダー、スキーマ ファイルに関するすべての情報が含まれています。

drizzle.config.ts
import { env } from '@/env'
import { config } from 'dotenv'
import { defineConfig } from 'drizzle-kit'

config({ path: '.env' })

export default defineConfig({
  schema: './src/db/schema.ts',
  out: './migrations',
  dialect: 'turso',
  dbCredentials: {
    url: env.TURSO_CONNECTION_URL,
    authToken: env.TURSO_AUTH_TOKEN,
  },
})

ここまででDB接続の記述は完了です。
あとはDBスキーマを用意し、drizzle-kit generateでmigrationファイルを作成し、drizzle-kit migrateでDBに反映を行うだけです
(あるいはdrizzle-kit pushで直接反映させることも可能です)

DBスキーマの作成

こちらは、Auth.jsに記載のあるスキーマを拝借し、それを編集して使うことにします。

https://authjs.dev/getting-started/adapters/drizzle?framework=next-js

作成したスキーマは以下です。
今回は二要素認証の実装等は行わないので、以下のようなシンプルな構成としています。

db/schema.ts
import { sql } from 'drizzle-orm'
import {
  index,
  integer,
  primaryKey,
  sqliteTable,
  text,
  uniqueIndex,
} from 'drizzle-orm/sqlite-core'
import type { AdapterAccountType } from 'next-auth/adapters'

export const tasksTable = sqliteTable('tasks', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  title: text('title').notNull(),
  isCompleted: integer('is_completed', { mode: 'boolean' })
    .notNull()
    .$default(() => false),
  createdAt: text('created_at').default(sql`(CURRENT_TIMESTAMP)`).notNull(),
  updateAt: integer('updated_at', { mode: 'timestamp' }).$onUpdate(
    () => new Date(),
  ),
})

export type InsertPost = typeof tasksTable.$inferInsert
export type SelectPost = typeof tasksTable.$inferSelect

export const users = sqliteTable(
  'user',
  {
    id: text('id')
      .primaryKey()
      .$defaultFn(() => crypto.randomUUID()),
    name: text('name'),
    email: text('email').unique(),
    emailVerified: integer('emailVerified', { mode: 'timestamp_ms' }),
    hashedPassword: text('hashedPassword'),
    image: text('image'),
    createdAt: text('created_at').default(sql`(CURRENT_TIMESTAMP)`).notNull(),
    updateAt: integer('updated_at', { mode: 'timestamp_ms' }).$onUpdate(
      () => new Date(),
    ),
  },
  (user) => [uniqueIndex('email').on(user.email)],
)

export type InsertUser = typeof users.$inferInsert
export type SelectUser = typeof users.$inferSelect

export const accounts = sqliteTable(
  'account',
  {
    userId: text('userId')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    type: text('type').$type<AdapterAccountType>().notNull(),
    provider: text('provider').notNull(),
    providerAccountId: text('providerAccountId').notNull(),
    refresh_token: text('refresh_token'),
    access_token: text('access_token'),
    expires_at: integer('expires_at'),
    token_type: text('token_type'),
    scope: text('scope'),
    id_token: text('id_token'),
    session_state: text('session_state'),
  },
  (account) => [
    primaryKey({ columns: [account.provider, account.providerAccountId] }),
    index('userId').on(account.userId),
  ],
)

export type InsertAccount = typeof accounts.$inferInsert

export const sessions = sqliteTable('session', {
  sessionToken: text('sessionToken').primaryKey(),
  userId: text('userId')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  expires: integer('expires', { mode: 'timestamp_ms' }).notNull(),
})

export type InsertSession = typeof sessions.$inferInsert

それでは、以下のコマンドを実行し、DBに対して、migrationを実行します。

fish
bunx drizzle-kit generate
bunx drizzle-kit migrate

Auth.jsによる認証実装

それでは、ここからAuth.jsによる認証実装をしていきます。

auth.ts作成

まず、アプリのルートディレクトリ、もしくはsrcディレクトリにauth.tsを作成します。
ここで、NextAuthという関数に認証の処理を渡すことで、返却されたsignInなどの関数でその処理を実行できます。
例えば、OAuthのGithubの認証を行うようNextAuthに処理を渡したら、signIn('github')のようにgithubの認証を行うことが可能になるというイメージです。

src/auth.ts
import { config } from '@/lib/auth/config'
import NextAuth from 'next-auth'

export const { handlers, auth, signIn, signOut } = NextAuth(config)

今回は、GitHub・Google認証のほか、Email・PasswordによるCredentialsの実装を行うため、別ファイルにconfigとして認証処理の実態を切り出しています。
それが、以下になります。

lib/auth/config.ts
import { db } from '@/db/db'
import { accounts, sessions, users } from '@/db/schema'
import { env } from '@/env'
import { DrizzleAdapter } from '@auth/drizzle-adapter'
import bcrypt from 'bcryptjs'
import { eq } from 'drizzle-orm'
import type { NextAuthConfig } from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import GitHub from 'next-auth/providers/github'
import Google from 'next-auth/providers/google'

export const config = {
  adapter: DrizzleAdapter(db, {
    usersTable: users,
    accountsTable: accounts,
    sessionsTable: sessions,
  }),
  callbacks: {
    async signIn({ user, account }) {
      if (account?.provider === 'github' || account?.provider === 'google') {
        return true
      }

      if (!user.id) {
        return false
      }

      const existingUser = await db.query.users.findFirst({
        where: eq(users.id, user.id),
      })

      if (!existingUser) {
        return false
      }

      return true
    },

    session({ session, token }) {
      if (token.sub) {
        session.user.id = token.sub
      }

      return session
    },

    async jwt({ token }) {
      if (!token.sub) {
        return token
      }

      const existingUser = await db.query.users.findFirst({
        where: eq(users.id, token.sub),
      })

      if (!existingUser) {
        return token
      }

      token.name = existingUser.name
      token.email = existingUser.email
      token.image = existingUser.image

      return token
    },
  },
  providers: [
    GitHub({
      clientId: env.GITHUB_CLIENT_ID,
      clientSecret: env.GITHUB_CLIENT_SECRET,
    }),
    Google({
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    }),
    Credentials({
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      authorize: async (credentials) => {
        if (
          !(
            typeof credentials?.email === 'string' &&
            typeof credentials?.password === 'string'
          )
        ) {
          throw new Error('Invalid credentials.')
        }

        const user = await db.query.users.findFirst({
          where: eq(users.email, credentials.email),
        })

        if (!user?.hashedPassword) {
          throw new Error('User has no password')
        }

        const isCorrectPassword = await bcrypt.compare(
          credentials.password,
          user.hashedPassword,
        )

        if (!isCorrectPassword) {
          throw new Error('Incorrect password')
        }

        return {
          id: user.id,
          name: user.name,
          email: user.email,
        }
      },
    }),
  ],
  pages: {
    signIn: '/sign-in',
  },
  trustHost: true,
  debug: process.env.NODE_ENV === 'development',
  session: { strategy: 'jwt' },
  secret: env.AUTH_SECRET,
} as const satisfies NextAuthConfig

細かい部分の説明ですが、ざっくりと以下のようになります。

  • callbacks
  • signIn: サインイン時に実行されるコールバック(Providersの認証後に行われる処理、GitHubならGitHub認証後、email・passwordならCredentialsのauthorizeの後に実行される)
  • session: tokenから取得したユーザーIDをsessionに設定
  • jwt: tokenのカスタマイズ
  • providers
  • GitHub: GitHub Applicationにて作成したapplicationの情報を渡す
  • Google: GoogleのOAuth設定にて、取得した情報を渡す
  • Credetials
    • credentials: 何を使って認証を行うかを設定(今回はemail・passwordを使用)
    • authorize: callabacksのsignInの前に行われる処理(成功時にはユーザー情報を、失敗時にはエラーを返却)
  • pages
    • signIn: カスタムログインページのパスを指定
  • trustHost: ホストを信頼するかを決める(local build時に、この設定がないとエラーとなる)
  • debug: 開発環境でdebugログを出す
  • session: jwtを採用
  • secret: JWTの署名を行う秘密鍵

GithubのApplicationの作成は以下の記事を参考に行い、CLIENT_IDCLIENT_SECRETを取得してください。

https://zenn.dev/pino0701/articles/nextjs_oauth

Gogleについては以下の記事でかなり詳細に記載されているので、こちらを参照し、Github同様にCLIENT_IDCLIENT_SECRETを取得してください。

https://zenn.dev/hayato94087/articles/91179fbbe1cad4

各ProviderでID・SECRETを取得できたら、環境変数に追加しましょう。

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

export const env = createEnv({
  server: {
    TURSO_AUTH_TOKEN: z.string(),
    TURSO_CONNECTION_URL: z.string().url(),
    AUTH_SECRET: z.string(),
+   GITHUB_CLIENT_ID: z.string(),
+    GITHUB_CLIENT_SECRET: z.string(),
+    GOOGLE_CLIENT_ID: z.string(),
+    GOOGLE_CLIENT_SECRET: z.string(),
  },
  runtimeEnv: {
    TURSO_CONNECTION_URL: process.env.TURSO_CONNECTION_URL,
    TURSO_AUTH_TOKEN: process.env.TURSO_AUTH_TOKEN,
    AUTH_SECRET: process.env.AUTH_SECRET,
+    GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
+    GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
+    GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
+    GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
  },
})

Route Handler作成

次に、Catch-all-segmentsにて、Route Handlerを作成していきます。
これで、/api/authで来たリクエストをAuth.jsがハンドリングできるようになります。

app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'

export const { GET, POST } = handlers

ここまでで認証のAPI側の処理が実装しましたので、UIとServerActionsの実装に移ります

UIの実装

ConformのuseFormを拡張

まずはじめに、今回使用するConformのuseFormを少し拡張します。
以下の記事を参照し、defaultValueを必須で定義するようにし、より厳格な定義ができるようにしていきます。

https://zenn.dev/yuitosato/articles/292f13816993ef#1.-useformをラップしてタイプセーフにする

use-safe-form.ts
import { useForm } from '@conform-to/react'

type FieldValues<T extends Record<string, any>> = Parameters<
  typeof useForm<T>
>[0]

export const useSafeForm = <T extends Record<string, any>>(
  options: Omit<FieldValues<T>, 'defaultValue'> &
    Required<Pick<FieldValues<T>, 'defaultValue'>>,
): ReturnType<typeof useForm<T>> => useForm<T>(options)

signUp UIの作成

続いて、sign-upのUIの解説です。
今回はConformを使用した実装を行っているので、各Form要素に対してgetXXXProps()を使用しています。
これによりプログレッシブ・・エンハンスメントな実装が行えます。

sign-up-form.tsx
'use client'

import {
  Button,
  Form,
  Loader,
  Separator,
  TextField,
} from '@/components/justd/ui'
import { OauthButton } from '@/features/auth/components/oauth-button'
import { useOauthSignIn } from '@/features/auth/hooks/use-oauth-sign-in'
import { useSignUp } from '@/features/auth/hooks/use-sign-up'
import { getFormProps, getInputProps } from '@conform-to/react'
import {
  IconBrandGithub,
  IconBrandGoogle,
  IconTriangleExclamation,
} from 'justd-icons'
import { Fragment } from 'react'

export const SignUpForm = () => {
  const { form, action, lastResult, isPending, fields } = useSignUp()
  const { isPending: isOauthPending, action: oauthAction } = useOauthSignIn()

  return (
    <>
      <Form {...getFormProps(form)} action={action} className="space-y-2.5">
        {lastResult?.error && Array.isArray(lastResult.error.message) && (
          <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>{lastResult.error.message.join(', ')}</p>
          </div>
        )}

        <div>
          <TextField
            {...getInputProps(fields.email, { type: 'email' })}
            placeholder="Email"
            defaultValue={lastResult?.initialValue?.email.toString() ?? ''}
            isDisabled={isPending || isOauthPending}
            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"
            defaultValue={lastResult?.initialValue?.password.toString() ?? ''}
            isDisabled={isPending || isOauthPending}
            errorMessage={''}
          />
          <span id={fields.password.errorId} className="text-sm text-red-500">
            {fields.password.errors && fields.password.errors?.length > 1
              ? fields.password.errors.map((error) => (
                  <Fragment key={error}>
                    {error}
                    <br />
                  </Fragment>
                ))
              : fields.password.errors}
          </span>
        </div>
        <div>
          <TextField
            {...getInputProps(fields.passwordConfirmation, {
              type: 'password',
            })}
            placeholder="Password Confirmation"
            defaultValue={
              lastResult?.initialValue?.passwordConfirmation.toString() ?? ''
            }
            isDisabled={isPending || isOauthPending}
            errorMessage={''}
          />
          <span
            id={fields.passwordConfirmation.errorId}
            className="text-sm text-red-500"
          >
            {fields.passwordConfirmation.errors &&
            fields.passwordConfirmation.errors?.length > 1
              ? fields.passwordConfirmation.errors.map((error) => (
                  <Fragment key={error}>
                    {error}
                    <br />
                  </Fragment>
                ))
              : fields.passwordConfirmation.errors}
          </span>
        </div>
        <Button
          type="submit"
          intent="secondary"
          size="large"
          isDisabled={isPending || isOauthPending}
          className="w-full text-white bg-zinc-900 hover:bg-zinc-950 data-hovered:bg-zinc-800/90 data-pressed:bg-zinc-800/90 relative"
        >
          Continue
          {isPending && <Loader className="absolute top-3 right-3" />}
        </Button>
      </Form>
      <Separator />
      <div className="flex flex-col gap-y-2.5">
        <OauthButton
          isDisabled={isOauthPending}
          isPending={isPending}
          onClick={() => oauthAction('github')}
          icon={IconBrandGithub}
          label="Continue with Github"
        />
        <OauthButton
          isDisabled={isOauthPending}
          isPending={isPending}
          onClick={() => oauthAction('google')}
          icon={IconBrandGoogle}
          label="Continue with Github"
        />
      </div>
    </>
  )
}

そして、useSignUpの内容は以下のとおりです
ここでは、useActionStateにてserver actionsの状態管理等を行うようにしています。

useSignUp
import { signUpAction } from '@/features/auth/actions/sign-up-action'
import {
  type SignUpInput,
  signUpInputSchema,
} from '@/features/auth/types/schemas/sign-up-input-schema'
import { useSafeForm } from '@/hooks/use-safe-form'
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
import { useActionState } from 'react'

export const useSignUp = () => {
  const [lastResult, action, isPending] = useActionState(signUpAction, null)

  const [form, fields] = useSafeForm<SignUpInput>({
    constraint: getZodConstraint(signUpInputSchema),
    lastResult,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: signUpInputSchema })
    },
    defaultValue: {
      email: '',
      password: '',
      passwordConfirmation: '',
    },
  })

  return {
    form,
    fields,
    lastResult,
    action,
    isPending,
  }
}

また、zodのSchemaは以下のように、一般的な実装を行っています。

sign-up-input-schema.ts
import { z } from 'zod'

const letterRegex = /[A-Za-z]/
const numberRegex = /[0-9]/

export const signUpInputSchema = z
  .object({
    email: z
      .string({ required_error: 'Email is required' })
      .email()
      .max(128, { message: 'Email is too long' }),
    password: z
      .string({ required_error: 'Password is required' })
      .min(8, { message: 'Password is too short' })
      .max(256, { message: 'Password is too long' })
      .refine(
        (password: string) =>
          letterRegex.test(password) && numberRegex.test(password),
        'password must contain both letters and numbers',
      ),
    passwordConfirmation: z
      .string({ required_error: 'Password confirmation is required' })
      .min(8, { message: 'Password confirmation is too short' })
      .max(256, { message: 'Password confirmation is too long' })
      .refine(
        (password: string) =>
          letterRegex.test(password) && numberRegex.test(password),
        'password must contain both letters and numbers',
      ),
  })
  .refine((data) => data.password === data.passwordConfirmation, {
    message: 'Passwords do not match',
    path: ['passwordConfirmation'],
  })

export type SignUpInput = z.infer<typeof signUpInputSchema>

signUpのServer Actions実装

そして、今回の肝となるServer Actionsの実装ですが、以下のようになります

sign-up-action.ts
'use server'

import { signIn } from '@/auth'
import { db } from '@/db/db'
import { users } from '@/db/schema'
import { signUpInputSchema } from '@/features/auth/types/schemas/sign-up-input-schema'
import { parseWithZod } from '@conform-to/zod'
import bcrypt from 'bcryptjs'
import { eq } from 'drizzle-orm'

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

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

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

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

  const hashedPassword = await bcrypt.hash(submission.value.password, 10)

  const [newUser] = await db
    .insert(users)
    .values({
      email: submission.value.email,
      hashedPassword,
      image: '',
    })
    .returning()

  await signIn('credentials', {
    email: newUser.email,
    password: submission.value.password,
    redirect: true,
    redirectTo: '/',
  })

  return submission.reply()
}

重要なのはsignInをユーザー登録後に呼んでいる部分になります。
これを呼ばないと登録時signInされず、DB登録だけされ、認証がされていない状態になるため、登録と同時に認証も済ませたい場合はこのようにします。

そして、signInが成功した場合は、redirectでホームページに遷移します。

また、Credentilasプロバイダーの処理で、signInを使用した際は以下の順で処理が進みます。

  1. config.tsで定義した、authorizeの処理が行われる
  2. config.tscallbacks.signInの処理が行われる
  3. signIn成功時config.tscallbacksjwtを行い、トークンの内容を決める
  4. callbackssessionを行い、sessionの内容をカスタマイズする

それでは、各オプションを詳細に見てみましょう。
authorizeではCredentialsの情報とDBの情報を照合して、正しいユーザーかを判定しています。

config.ts
authorize: async (credentials) => {
  if (
    !(
      typeof credentials?.email === 'string' &&
      typeof credentials?.password === 'string'
    )
  ) {
    throw new Error('Invalid credentials.')
  }

  const user = await db.query.users.findFirst({
    where: eq(users.email, credentials.email),
  })

  if (!user?.hashedPassword) {
    throw new Error('User has no password')
  }

  const isCorrectPassword = await bcrypt.compare(
    credentials.password,
    user.hashedPassword,
  )

  if (!isCorrectPassword) {
    throw new Error('Incorrect password')
  }

  return {
    id: user.id,
    name: user.name,
    email: user.email,
  }
}

そして、callbacksですが、signInでは、まずproviderの判定をしており、crendentialsの場合は、処理を続行します。
signInの返却値がfalseだと認証失敗でエラーとなります。

そして、signInに成功すると、jwtにてトークンの内容を決めます。
ここでは、主にトークンにログインユーザーの情報を持たすようにしています。

そして、sessionでは、token.sub(ユーザーID)がある場合にsession.user.idを上書きするようにしています。
これがsignInが呼び出された場合の処理です。

config.ts
callbacks: {
  async signIn({ user, account }) {
    if (account?.provider === 'github' || account?.provider === 'google') {
      return true
    }

    if (!user.id) {
      return false
    }

    const existingUser = await db.query.users.findFirst({
      where: eq(users.id, user.id),
    })

    if (!existingUser) {
      return false
    }

    return true
  },

  session({ session, token }) {
    if (token.sub) {
      session.user.id = token.sub
    }

    return session
  },

  async jwt({ token }) {
    if (!token.sub) {
      return token
    }

    const existingUser = await db.query.users.findFirst({
      where: eq(users.id, token.sub),
    })

    if (!existingUser) {
      return token
    }

    token.name = existingUser.name
    token.email = existingUser.email
    token.image = existingUser.image

    return token
  },
}

次に、signInのUIについても見てみましょう。

signIn UIの実装

ここはほぼ、signUpと同じなので、説明は割愛します。

sign-in-form.tsx
'use client'

import {
  Button,
  Form,
  Loader,
  Separator,
  TextField,
} from '@/components/justd/ui'
import { OauthButton } from '@/features/auth/components/oauth-button'
import { useOauthSignIn } from '@/features/auth/hooks/use-oauth-sign-in'
import { useSignIn } from '@/features/auth/hooks/use-sign-in'
import { getFormProps, getInputProps } from '@conform-to/react'
import { IconBrandGithub, IconBrandGoogle } from 'justd-icons'
import { Fragment } from 'react'

export const SignInForm = () => {
  const { form, action, fields, lastResult, isPending } = useSignIn()
  const { isPending: isOauthPending, action: oauthAction } = useOauthSignIn()

  return (
    <>
      <Form {...getFormProps(form)} action={action} className="space-y-2.5">
        <div>
          <TextField
            {...getInputProps(fields.email, { type: 'email' })}
            placeholder="Email"
            defaultValue={lastResult?.initialValue?.email.toString() ?? ''}
            isDisabled={isPending || isOauthPending}
            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"
            defaultValue={lastResult?.initialValue?.password.toString() ?? ''}
            isDisabled={isPending || isOauthPending}
            errorMessage={''}
          />
          <span id={fields.password.errorId} className="text-sm text-red-500">
            {fields.password.errors && fields.password.errors?.length > 1
              ? fields.password.errors.map((error) => (
                  <Fragment key={error}>
                    {error}
                    <br />
                  </Fragment>
                ))
              : fields.password.errors}
          </span>
        </div>
        <Button
          type="submit"
          intent="secondary"
          size="large"
          isDisabled={isPending || isOauthPending}
          className="w-full text-white bg-zinc-900 hover:bg-zinc-950 data-hovered:bg-zinc-800/90 data-pressed:bg-zinc-800/90 relative"
        >
          Continue
          {isPending && <Loader className="absolute top-3 right-3" />}
        </Button>
      </Form>
      <Separator />
      <div className="flex flex-col gap-y-2.5">
        <OauthButton
          isDisabled={isOauthPending}
          isPending={isPending}
          onClick={() => oauthAction('github')}
          icon={IconBrandGithub}
          label="Continue with Github"
        />
        <OauthButton
          isDisabled={isOauthPending}
          isPending={isPending}
          onClick={() => oauthAction('google')}
          icon={IconBrandGoogle}
          label="Continue with Github"
        />
      </div>
    </>
  )
}

カスタムフックの内容もほとんど同じと言っていいと思います。

use-sign-in.ts
import { signInAction } from '@/features/auth/actions/sign-in-action'
import {
  type SignInInput,
  signInInputSchema,
} from '@/features/auth/types/schemas/sign-in-input-schema'
import { useSafeForm } from '@/hooks/use-safe-form'
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
import { useActionState } from 'react'

export const useSignIn = () => {
  const [lastResult, action, isPending] = useActionState(signInAction, null)

  const [form, fields] = useSafeForm<SignInInput>({
    constraint: getZodConstraint(signInInputSchema),
    lastResult,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: signInInputSchema })
    },
    defaultValue: {
      email: '',
      password: '',
    },
  })

  return {
    form,
    fields,
    lastResult,
    action,
    isPending,
  }
}

以下が、signInのUIで使用するzod Schemaです。

sign-in-input-schema.ts
import { z } from 'zod'

const letterRegex = /[A-Za-z]/
const numberRegex = /[0-9]/

export const signInInputSchema = z.object({
  email: z
    .string({ required_error: 'Email is required' })
    .email()
    .max(128, { message: 'Email is too long' }),
  password: z
    .string({ required_error: 'Password is required' })
    .min(8)
    .max(256, { message: 'Password is too long' })
    .refine(
      (password: string) =>
        letterRegex.test(password) && numberRegex.test(password),
      'password must contain both letters and numbers',
    ),
})

export type SignInInput = z.infer<typeof signInInputSchema>

次にServer Actionsです。

signInのServer Actions実装

sign-upほど複雑ではないのは、ユーザー登録がない分処理が減っているためです。
ここでもAuth.jsのsignInCredentialsで実行し、authorizeおよびcallbacksの処理を実行し、認証処理を行います。

sign-in-action.ts
'use server'

import { signIn } from '@/auth'
import { signInInputSchema } from '@/features/auth/types/schemas/sign-in-input-schema'
import { parseWithZod } from '@conform-to/zod'

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

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

  await signIn('credentials', {
    email: submission.value.email,
    password: submission.value.password,
    redirect: true,
    redirectTo: '/',
  })

  return submission.reply()
}

OAuthの認証実装

OAuthの認証は以下のカスタムフックで行っております。
特にactionの結果を受け取る必要はないため、actionとisPendingのみ使用するようにしています。

use-oauth-sign-in.ts
import { oauthSignInAction } from '@/features/auth/actions/o-auth-sign-in-action'
import { useActionState } from 'react'

export const useOauthSignIn = () => {
  const [, action, isPending] = useActionState(oauthSignInAction, null)

  return {
    action,
    isPending,
  }
}

server actions側の実装は以下のとおりです。
providerを渡して、providerに応じたsignInを行うようにしています。

ちなみに、providerの型をstringにするとstring literal typesでライブラリでは定義されているものがstringとなりwideningが発生するため、UtlityのParameters<T>を使用するようにしています。

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

import { signIn } from '@/auth'

export const oauthSignInAction = async (
  _: unknown,
  provider: Parameters<typeof signIn>[0],
) => {
  await signIn(provider, { redirect: true, redirectTo: '/' })
}

今回の場合、GithubとGoogleを使用しているので、config.tsprovidersGitHubGoogleに処理がわたり、その後、callbackssignInが実行されます。

ただし、今回はproviderによる認証なので、即trueを返すようにして、Auth.jsでの認証処理から外部のサービスによる認証を行うようにしています。
ここを条件分岐にして、trueで返却してあげないと、エラーとなるか、!user.idの条件に移り、予期しない処理やエラーが発生する可能性があるため、この分岐をしてAuth.jsの認証(signIn)で追加の処理をしないようにしています。

jwtsessionについては外部アカウントの認証後、Credentialsの時と同様に実施されます。

config.ts
callbacks: {
  async signIn({ user, account }) {
    if (account?.provider === 'github' || account?.provider === 'google') {
      return true
    }

    if (!user.id) {
      return false
    }

    const existingUser = await db.query.users.findFirst({
      where: eq(users.id, user.id),
    })

    if (!existingUser) {
      return false
    }

    return true
  },

  session({ session, token }) {
    if (token.sub) {
      session.user.id = token.sub
    }

    return session
  },

  async jwt({ token }) {
    if (!token.sub) {
      return token
    }

    const existingUser = await db.query.users.findFirst({
      where: eq(users.id, token.sub),
    })

    if (!existingUser) {
      return token
    }

    token.name = existingUser.name
    token.email = existingUser.email
    token.image = existingUser.image

    return token
  },
}

実際にUI上では以下のようにして使用しています。
各ボタンに応じてproviderを呼び出しています。

<div className="flex flex-col gap-y-2.5">
  <OauthButton
    isDisabled={isOauthPending}
    isPending={isPending}
    onClick={() => oauthAction('github')}
    icon={IconBrandGithub}
    label="Continue with Github"
  />
  <OauthButton
    isDisabled={isOauthPending}
    isPending={isPending}
    onClick={() => oauthAction('google')}
    icon={IconBrandGoogle}
    label="Continue with Github"
  />
</div>

ここまでで認証の処理は完了です。

ユーザー情報取得

ユーザー情報の取得はsessionから取得するのですが、以下のようにauthを呼ぶだけで取得できます。
何回も使用する関数なので、cacheしておくとパフォーマンス上良いため、cacheしてあります。

session.ts
import { auth } from '@/auth'
import { cache } from 'react'

export const getSession = cache(auth)

返却値は以下のとおりで、しっかりとcallbacksのjwtやsessionで設定した返却値が返されていることがわかります。

const session: {
    user: User | undefined;
    expires: string;
} | null

type user = {
    id: string | undefined;
    name: string | null | undefined;
    email: string | null | undefined;
    image: string | null | undefined;
} | undefined

sign outの実装

Auth.js v5では、signOutもserver actionsで実行できるようなので、そのようにします。
まずはUIですが、以下のようにして、server actionsを呼び出します。

Home.tsx
'use client'

import { signOutAction } from '@/features/auth/actions/sign-out-action'

const Home = () => {
  return (
    <button
      type="button"
      onClick={async () => {
        await signOutAction()
      }}
    >
      sign out
    </button>
  )
}

export default Home

そして、server actionsは以下の通りで、signIn()と似た形式で、signOut成功後にredirectでsign-inページにredirectするようにしています。

sign-out.action.ts
'use server'

import { signOut } from '@/auth'

export const signOutAction = async () => {
  await signOut({ redirect: true, redirectTo: '/sign-in' })
}

以上で、signOutの実装も完了です。

middlewareの実装

最後に、middlewareの実装を行います。
ここでは、未ログインの場合、sign-inにredirectされるような処理を作成します。

https://authjs.dev/getting-started/migrating-to-v5#details

上記ドキュメントにあるように、Auth.js v5ではauthを呼び出すことで、拡張されたRequestオブジェクトを引数に受けられるため、簡単に認証済みかどうかの判定を行うことができます。

これにより、未認証の場合はsign-inにredirectさせることができます。
また、/api/auth/callbackのパスでリクエストされるProviderの認証では、middlewareがリクエスト前に行われるので、middlewareの対象外となるようmathcherapiのパスを除外するようにしています。

src/middleware.ts
import { auth } from '@/auth'
import { NextResponse } from 'next/server'

export default auth((req) => {
  const isLoggedIn = !!req.auth

  if (!isLoggedIn) {
    return NextResponse.redirect(new URL('/sign-in', req.nextUrl))
  }

  return NextResponse.next()
})

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

また、今回はmatchersign-inおよびsign-upも除外しているため、認証ページのlayout.tsxで認証されている場合は、ホーム画面にredirectされるようにしてあげます。

app/(auth)/layout.tsx
import { getSession } from '@/lib/auth/session'
import { redirect } from 'next/navigation'
import type { ReactNode } from 'react'

const AuthLayout = async ({ children }: { children: ReactNode }) => {
  const session = await getSession()

  if (session) {
    redirect('/')
  }

  return (
    <div className="h-full flex items-center justify-center bg-[#5C3B58]">
      <div className="md:h-auto md:w-[420px]">{children}</div>
    </div>
  )
}

export default AuthLayout

これで認証の処理はほとんど網羅できたのではと思います。

まとめ

React19のuseActionStateおよび、Auth.js v5 とConformを使えば、簡単にAuth.jsでもServer Actionメインにできてしまうということがお分かりいただけたのではないでしょうか。

action属性はHTMLだけで使用できるため、プログレッシブ・エンハンスメントに乗っとているだけでなく、JSがなくても動くため、今後ますます使われるので、このような実装は一般的になるのではと私は考えています。

少しでも参考になれば幸いです。

参考文献

https://authjs.dev/

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

https://ja.conform.guide/integration/nextjs

https://qiita.com/curry__30/items/b895d092555a2318c7c6

https://zenn.dev/pino0701/articles/nextjs_oauth

https://zenn.dev/hayato94087/articles/91179fbbe1cad4

7

Discussion

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