【Auth.js v5 ✗ Next.js 15】Server Actionsで実装する認証機能
はじめに
今回はAuth.js v5とNext.js 15およびReact19による、Server Actions対応版の認証機能の実装方法について解説したいと思います
技術スタック
最初に、今回使用する技術スタックを紹介します。
以下のようになっているので、認証関連の処理以外の、各セクションの事細かなコードについては、ドキュメントを参照いただければと思います。
- Next.js v15
- React 19
- Auth.js v5: 認証ライブラリ
- Conform: form管理ライブラリ
- Justd: コンポーネントライブラリ
- Turos: DB
- Drizzle: ORM
認証処理
さっそく実装に入ります。
まずは、今回の実装に必要なライブラリを導入します。
必要なライブラリを導入
以下のコマンドを実行し、必要なライブラリを導入しましょう。
bun add next-auth@beta bcryptjs @conform-to/react @conform-to/zod drizzle-orm @libsql/client @auth/drizzle-adapter @t3-oss/env-nextjs
bun add -D drizzle-kit @types/bcryptjs
続いて、Auth.jsのinstallationにある通り以下のコマンドを実行し、Auth.jsに必要な環境変数を作成していきます。
bunx auth secret
ここまででライブラリの導入は完了しました。
DB接続
以下のDrizzleドキュメントに従い、Tursoの環境を構築していきます。
ドキュメントにある手順に従い、コマンドの実行及び、環境変数等の定義をしていきます。
環境変数については、型安全に扱うためのライブラリである@t3-oss/env-nextjs
により、以下のようにzodの型定義をして、このファイルから使用するようにします。
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に接続するためのコードを作成します。
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内ではないので注意が必要です)
このファイルには以下の情報が含まれます。
データベース接続、移行フォルダー、スキーマ ファイルに関するすべての情報が含まれています。
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に記載のあるスキーマを拝借し、それを編集して使うことにします。
作成したスキーマは以下です。
今回は二要素認証の実装等は行わないので、以下のようなシンプルな構成としています。
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を実行します。
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の認証を行うことが可能になるというイメージです。
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
として認証処理の実態を切り出しています。
それが、以下になります。
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
-
credential
s: 何を使って認証を行うかを設定(今回はemail・passwordを使用) -
authorize
:callabacks
のsignInの前に行われる処理(成功時にはユーザー情報を、失敗時にはエラーを返却)
-
-
pages
-
signIn
: カスタムログインページのパスを指定
-
-
trustHost
: ホストを信頼するかを決める(local build時に、この設定がないとエラーとなる) -
debug
: 開発環境でdebugログを出す -
session
: jwtを採用 -
secret
: JWTの署名を行う秘密鍵
GithubのApplicationの作成は以下の記事を参考に行い、CLIENT_ID
・CLIENT_SECRET
を取得してください。
Gogleについては以下の記事でかなり詳細に記載されているので、こちらを参照し、Github同様にCLIENT_ID
とCLIENT_SECRET
を取得してください。
各ProviderでID・SECRETを取得できたら、環境変数に追加しましょう。
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がハンドリングできるようになります。
import { handlers } from '@/auth'
export const { GET, POST } = handlers
ここまでで認証のAPI側の処理が実装しましたので、UIとServerActionsの実装に移ります
UIの実装
ConformのuseFormを拡張
まずはじめに、今回使用するConformのuseForm
を少し拡張します。
以下の記事を参照し、defaultValue
を必須で定義するようにし、より厳格な定義ができるようにしていきます。
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()
を使用しています。
これによりプログレッシブ・・エンハンスメントな実装が行えます。
'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の状態管理等を行うようにしています。
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は以下のように、一般的な実装を行っています。
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の実装ですが、以下のようになります
'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を使用した際は以下の順で処理が進みます。
-
config.ts
で定義した、authorize
の処理が行われる -
config.ts
のcallbacks.signIn
の処理が行われる -
signIn
成功時config.ts
のcallbacks
のjwt
を行い、トークンの内容を決める -
callbacks
のsession
を行い、sessionの内容をカスタマイズする
それでは、各オプションを詳細に見てみましょう。
authorize
ではCredentialsの情報とDBの情報を照合して、正しいユーザーかを判定しています。
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が呼び出された場合の処理です。
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と同じなので、説明は割愛します。
'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>
</>
)
}
カスタムフックの内容もほとんど同じと言っていいと思います。
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です。
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のsignIn
をCredentials
で実行し、authorize
およびcallbacks
の処理を実行し、認証処理を行います。
'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のみ使用するようにしています。
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>
を使用するようにしています。
'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.ts
のproviders
のGitHub
かGoogle
に処理がわたり、その後、callbacks
のsignIn
が実行されます。
ただし、今回はproviderによる認証なので、即trueを返すようにして、Auth.jsでの認証処理から外部のサービスによる認証を行うようにしています。
ここを条件分岐にして、trueで返却してあげないと、エラーとなるか、!user.id
の条件に移り、予期しない処理やエラーが発生する可能性があるため、この分岐をしてAuth.jsの認証(signIn
)で追加の処理をしないようにしています。
jwt
やsession
については外部アカウントの認証後、Credentialsの時と同様に実施されます。
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してあります。
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を呼び出します。
'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するようにしています。
'use server'
import { signOut } from '@/auth'
export const signOutAction = async () => {
await signOut({ redirect: true, redirectTo: '/sign-in' })
}
以上で、signOutの実装も完了です。
middlewareの実装
最後に、middlewareの実装を行います。
ここでは、未ログインの場合、sign-in
にredirectされるような処理を作成します。
上記ドキュメントにあるように、Auth.js v5ではauth
を呼び出すことで、拡張されたRequestオブジェクトを引数に受けられるため、簡単に認証済みかどうかの判定を行うことができます。
これにより、未認証の場合はsign-in
にredirectさせることができます。
また、/api/auth/callback
のパスでリクエストされるProviderの認証では、middlewareがリクエスト前に行われるので、middlewareの対象外となるようmathcher
にapi
のパスを除外するようにしています。
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)).*)',
],
}
また、今回はmatcher
でsign-in
およびsign-up
も除外しているため、認証ページのlayout.tsxで認証されている場合は、ホーム画面にredirectされるようにしてあげます。
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がなくても動くため、今後ますます使われるので、このような実装は一般的になるのではと私は考えています。
少しでも参考になれば幸いです。
参考文献
Discussion