木曜日, 10月 2, 2025
木曜日, 10月 2, 2025
- Advertisment -
ホームニューステックニュース【保存版】Convex × TanStack Start × Better Auth 認証実装ガイド

【保存版】Convex × TanStack Start × Better Auth 認証実装ガイド


今回ですが、Convex・TanStack StartにBetter Authを統合させた認証機能の実装をしたので、実装手順・方法を紹介したいと思います。

また、今回の実装を行ったソースは以下となります。

https://github.com/sc30gsw/tanstack-convex-better-auth-example

TL;DR

  • Convexを使用する場合、認証処理の中核(Better Authの主な機能実装)はConvex側で必要
  • TanStack StartではBetter Authの client定義が必要かつConvexのSiteURLを設定する必要がある
  • 本来であれば、beforeLoadで認証・未認証のハンドリングを行うべき(TanStack Start RC版ではバグがあるため断念)
  • ConvexとTanStack Start側でURLが異なるため、辻褄が合うよう実装する必要がある

まず、前提として、今回の技術スタックと処理フローを先に記載いたします。

技術スタック

処理フロー

  1. Authorized Componentでsessionの判定
  2. 認証済みの場合、ProtectedRouteにアクセス
  3. 未認証の場合、/sign-inにリダイレクト(認証済みの場合、/にリダイレクト)
    a. /sign-upは未認証でアクセス可能・認証済みでは/にリダレクト
  4. 画面からConvexに認証リクエスト
  5. Better AuthのHandlerで処理を実施

前提の共有が完了したところで、いよいよ実装に入っていきます。

実装手順は以下で、解説手順も同じ順に行います。

  1. 環境構築(Convex・TanStack Start統合)
  2. Convex・TanStack Startの両者にBetter Auth導入
  3. SignIn・SignUp・SignOutページの作成
  4. GitHub OAuth設定
  5. Session取得処理実装
  6. Authorized Component 作成

1. 環境構築(Convex・TanStack Start統合)

まずは、環境構築です。
以下のドキュメントを参考に実施していきます。

https://docs.convex.dev/quickstart/tanstack-start

ドキュメント通り、以下のコマンドでTanStack Start・Convexのテンプレートの作成をしていきます。
※ テンプレートでなく、1から統合していく場合、TanStack Startアプリケーション作成後、本ドキュメントの手順および、コードを取得して統合するようにしてください。

fish

bun create convex@latest -- -t tanstack-start

コマンド完了後、プロジェクトディレクトリに移動し、Convexのサーバーを起動します。

この時、ConvexにConvexプロジェクトがない場合、新規に作成する必要があるので、コマンドの手順に従い、Convexプロジェクトのセットアップを行ってください。

次に、TanStack Startが起動できるかも確認します。

起動が確認でき、http://localhost:3000にアクセスできたら、環境構築は完了です。

2. Convex・TanStack Startの両者にBetter Auth導入

次に、Better Authを両者に統合していきます。
こちらはBetter AuthのConvex integrationsを参考にしていきます。

https://www.better-auth.com/docs/integrations/convex

まずは、ドキュメントに従い必要なパッケージを導入してきます。

fish

bun add [email protected] --exact
bun add convex@latest @convex-dev/better-auth

次に、Convex側のBetter Auth環境を構築していきます。

Better Auth Convex統合

Convexの各種Config作成

まずは、convex.config.tsを作成し、以下を記述します。

convex/convex.config.ts

import { defineApp } from "convex/server";
import betterAuth from "@convex-dev/better-auth/convex.config";

const app = defineApp();
app.use(betterAuth);

export default app;

次に、auth.config.tsを作成し、以下を記述します。

convex/auth.config.ts

export default {
  providers: [
    {
      domain: process.env.CONVEX_SITE_URL,
      applicationID: "convex",
    },
  ]
};

Convex環境変数設定

各コマンドを実行し、環境変数を設定していきます。

fish

bun x convex env set BETTER_AUTH_SECRET=$(openssl rand -base64 32)
bun x convex env set SITE_URL http://localhost:3000

コマンド実行後、Convexダッシュボードに反映されていることを確認します。

initial env

ローカル環境変数設定

続いて、ローカル環境変数を設定します。
Convexサーバーを初回に起動したため、.env.localがすでに作成されており、Convexに必要な環境変数が定義されていると思います。

そのため、以下の2つの変数を追加します。

.env.local

# Deployment used by `npx convex dev`
CONVEX_DEPLOYMENT=dev:xxxxxxxx-yyyyy-zzz

VITE_CONVEX_URL=https://xxxxxxxx-yyyyy-zzz.convex.cloud

+ # Same as VITE_CONVEX_URL but ends in .site
+ VITE_CONVEX_SITE_URL=https://xxxxxxxx-yyyyy-zzz.convex.site
+ # Your local site URL
+ SITE_URL=http://localhost:3000

Better Auth Instance作成

ここはドキュメントと同じく、createAuthを定義していきます。
ここで、OAuthなど、どの認証処理を行うのかの定義を追加していきます。

convex/auth.ts

import { createClient, type GenericCtx } from "@convex-dev/better-auth";
import { convex } from "@convex-dev/better-auth/plugins";
import { components } from "./_generated/api";
import { DataModel } from "./_generated/dataModel";
import { query } from "./_generated/server";
import { betterAuth } from "better-auth";

const siteUrl = process.env.SITE_URL!;



export const authComponent = createClientDataModel>(components.betterAuth);

export const createAuth = (
  ctx: GenericCtxDataModel>,
    { optionsOnly } = { optionsOnly: false },
  ) => {
  return betterAuth({
    
    
    logger: {
      disabled: optionsOnly,
    },
    baseURL: siteUrl,
    database: authComponent.adapter(ctx),
    
    emailAndPassword: {
      enabled: true,
      requireEmailVerification: false,
    },
    plugins: [
      
      convex(),
    ],
  });
};



export const getCurrentUser = query({
  args: {},
  handler: async (ctx) => {
    return authComponent.getAuthUser(ctx);
  },
});

HTTP handler追加

次に、ConvexがBetter AuthのルートをMountできるようhttp.tsを作成します。
簡単に言うと、Convex 上で HTTP エンドポイントを作って、Better Auth の認証用ルートをマウントする処理を記述します。

これがあるおかげで、フロントエンドのauthClientでBetter Authの各種認証用ルートにConvex経由でアクセスすることができます。

convex/http.ts

import { httpRouter } from "convex/server";
import { authComponent, createAuth } from "./auth";

const http = httpRouter();

authComponent.registerRoutes(http, createAuth);

export default http;

これでConvexでのBetter Auth統合は完了です。

Better Auth TanStack Start統合

こちらで行うことは以下の2つだけです。

  1. AuthClientの作成
  2. ConvexBetterAuthProviderでRouteをラップする

1. AuthClientの作成

まずは,AuthClientの作成です。

lib/auth-client.tsを作成し、convexClient()をplugin指定して、フロントエンドで使う認証用Clientを作成します。

注意としては、baseURLにはConvex側のSITE URLを指定する必要があることです。
アプリケーション側のURL(http://localhost:3000など)を指定するのは誤りであるということです。

lib/auth-client.ts

import { convexClient } from '@convex-dev/better-auth/client/plugins'
import { createAuthClient } from 'better-auth/react'

export const authClient = createAuthClient({
  
  
  baseURL: `${import.meta.env.VITE_CONVEX_SITE_URL}/api/auth`,
  plugins: [convexClient()],
})

2. ConvexBetterAuthProviderでRouteをラップする

以下の修正をrouter.tsx行います。

  1. ConvexReactClientの作成
  2. ConvexProviderをConvexBetterAuthProviderに修正

rsc/router.tsx

import { ConvexBetterAuthProvider } from '@convex-dev/better-auth/react'
import { ConvexQueryClient } from '@convex-dev/react-query'
import { QueryClient } from '@tanstack/react-query'
import { createRouter } from '@tanstack/react-router'
import { routerWithQueryClient } from '@tanstack/react-router-with-query'
import { ConvexReactClient } from 'convex/react'
import { authClient } from '~/lib/auth-client'
import { routeTree } from './routeTree.gen'

export function getRouter() {
  const CONVEX_URL = (import.meta as any).env.VITE_CONVEX_URL!

  if (!CONVEX_URL) {
    console.error('missing envar CONVEX_URL')
  }

  const convex = new ConvexReactClient(CONVEX_URL, {
    
    expectAuth: true,
  })

  const convexQueryClient = new ConvexQueryClient(CONVEX_URL)

  const queryClient: QueryClient = new QueryClient({
    defaultOptions: {
      queries: {
        queryKeyHashFn: convexQueryClient.hashFn(),
        queryFn: convexQueryClient.queryFn(),
        gcTime: 5000,
      },
    },
  })
  convexQueryClient.connect(queryClient)

  const router = routerWithQueryClient(
    createRouter({
      routeTree,
      defaultPreload: 'intent',
      context: { queryClient },
      scrollRestoration: true,
      defaultPreloadStaleTime: 0, 
      defaultErrorComponent: (err) => p>{err.error.stack}p>,
      defaultNotFoundComponent: () => p>not foundp>,
      Wrap: ({ children }) => (
        ConvexBetterAuthProvider client={convex} authClient={authClient}>
          {children}
        ConvexBetterAuthProvider>
      ),
    }),
    queryClient,
  )

  return router
}

ConvexReactClientではexpectAuthを有効にしており、これを有効にすることで、未認証のユーザーがQuery・Mutatoinなどが行えないようにします。
例えば、Mutationの場合、ボタン自体は表示されますが、ボタンを押しても未認証の場合、何も反応がなく、リクエストさせることを防ぐことができます。

その他、ConvexReactClientには便利なオプションが提供されてるので詳細は、以下のドキュメントを参照してください。

https://docs.convex.dev/api/interfaces/react.ConvexReactClientOptions

以上で、手順2のBetter Auth統合が完了です。

3. SignIn・SignUp・SignOutページの作成

次にフロント側でUIを作成してきます。
※ いずれのページも主にJSX部分はAIで作成したため、冗長な箇所があると思いますが、ご容赦ください

SignIn ページの作成

ソースは以下になります。

https://github.com/sc30gsw/tanstack-convex-better-auth-example/blob/main/src/routes/auth/sign-in.tsx

全体像は以下です。

routes/auth/sign-in.tsx

import { useForm } from '@tanstack/react-form'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { AlertCircle, Github, Loader2, Lock, Mail } from 'lucide-react'
import { useTransition } from 'react'
import { authClient } from '~/lib/auth-client'

export const Route = createFileRoute('/auth/sign-in')({
  component: SignIn,
})

function SignIn() {
  const navigate = useNavigate()
  const [isPending, startTransition] = useTransition()

  const form = useForm({
    defaultValues: {
      email: '',
      password: '',
      error: '',
    },
    onSubmit: ({ value, formApi }) => {
      formApi.setFieldValue('error', '')

      startTransition(async () => {
        try {
          await authClient.signIn.email(
            {
              email: value.email,
              password: value.password,
            },
            {
              onSuccess: () => {
                navigate({ to: "https://zenn.dev/" })
              },
              onError: (ctx) => {
                formApi.setFieldValue('error', ctx.error.message || 'ログインに失敗しました')
              },
            },
          )
        } catch (_err) {
          formApi.setFieldValue('error', '予期しないエラーが発生しました')
        }
      })
    },
  })

  const handleGitHubSignIn = () => {
    form.setFieldValue('error', '')

    startTransition(async () => {
      try {
        await authClient.signIn.social({
          provider: 'github',
          callbackURL: 'http://localhost:3000/',
        })
      } catch (_err) {
        form.setFieldValue('error', 'GitHub認証に失敗しました')
      }
    })
  }

  return (
    div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4 dark:from-slate-900 dark:to-slate-800">
      div className="w-full max-w-md">
        div className="mb-8 text-center">
          h1 className="font-bold text-3xl text-slate-900 dark:text-slate-50">ログインh1>
          p className="mt-2 text-slate-600 text-sm dark:text-slate-400">
            アカウントにログインしてください
          p>
        div>

        div className="rounded-lg bg-white p-8 shadow-lg dark:bg-slate-800">
          {form.state.values.error && (
            div className="mb-4 flex items-center gap-2 rounded-md bg-red-50 p-3 text-red-800 text-sm dark:bg-red-900/20 dark:text-red-400">
              AlertCircle className="h-4 w-4 flex-shrink-0" />
              span>{form.state.values.error}span>
            div>
          )}

          form
            onSubmit={(e) => {
              e.preventDefault()
              e.stopPropagation()
              form.handleSubmit()
            }}
            className="space-y-4"
          >
            form.Field
              name="email"
              validators={{
                onChange: ({ value }) =>
                  !value
                    ? 'メールアドレスは必須です'
                    : !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
                      ? 'メールアドレスの形式が正しくありません'
                      : undefined,
              }}
            >
              {(field) => (
                div>
                  label
                    htmlFor={field.name}
                    className="mb-1 block font-medium text-slate-700 text-sm dark:text-slate-300"
                  >
                    メールアドレス
                  label>
                  div className="relative">
                    Mail className="absolute top-3 left-3 h-5 w-5 text-slate-400" />
                    input
                      id={field.name}
                      name={field.name}
                      type="email"
                      value={field.state.value}
                      onBlur={field.handleBlur}
                      onChange={(e) => field.handleChange(e.target.value)}
                      required
                      className="w-full rounded-md border border-slate-300 py-2 pr-3 pl-10 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
                      placeholder="[email protected]"
                      disabled={isPending}
                    />
                  div>
                  {field.state.meta.errors.length > 0 && (
                    p className="mt-1 text-red-600 text-xs dark:text-red-400">
                      {field.state.meta.errors[0]}
                    p>
                  )}
                div>
              )}
            form.Field>

            form.Field
              name="password"
              validators={{
                onChange: ({ value }) =>
                  !value
                    ? 'パスワードは必須です'
                    : value.length  8
                      ? 'パスワードは8文字以上で入力してください'
                      : undefined,
              }}
            >
              {(field) => (
                div>
                  label
                    htmlFor={field.name}
                    className="mb-1 block font-medium text-slate-700 text-sm dark:text-slate-300"
                  >
                    パスワード
                  label>
                  div className="relative">
                    Lock className="absolute top-3 left-3 h-5 w-5 text-slate-400" />
                    input
                      id={field.name}
                      name={field.name}
                      type="password"
                      value={field.state.value}
                      onBlur={field.handleBlur}
                      onChange={(e) => field.handleChange(e.target.value)}
                      required
                      minLength={8}
                      className="w-full rounded-md border border-slate-300 py-2 pr-3 pl-10 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
                      placeholder="••••••••"
                      disabled={isPending}
                    />
                  div>
                  {field.state.meta.errors.length > 0 && (
                    p className="mt-1 text-red-600 text-xs dark:text-red-400">
                      {field.state.meta.errors[0]}
                    p>
                  )}
                div>
              )}
            form.Field>

            button
              type="submit"
              disabled={isPending}
              className="flex w-full items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-slate-800"
            >
              {isPending ? (
                >
                  Loader2 className="h-5 w-5 animate-spin" />
                  ログイン中...
                >
              ) : (
                'ログイン'
              )}
            button>
          form>

          div className="my-6 flex items-center">
            div className="h-px flex-1 bg-slate-300 dark:bg-slate-600" />
            span className="px-4 text-slate-500 text-sm dark:text-slate-400">またはspan>
            div className="h-px flex-1 bg-slate-300 dark:bg-slate-600" />
          div>

          button
            type="button"
            onClick={handleGitHubSignIn}
            disabled={isPending}
            className="flex w-full items-center justify-center gap-2 rounded-md border-2 border-slate-300 bg-white px-4 py-2 font-medium text-slate-700 transition-colors hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200 dark:focus:ring-offset-slate-800 dark:hover:bg-slate-600"
          >
            Github className="h-5 w-5" />
            GitHubでログイン
          button>

          div className="mt-6 text-center text-slate-600 text-sm dark:text-slate-400">
            アカウントをお持ちでないですか?{' '}
            Link
              to="/auth/sign-up"
              className="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
            >
              新規登録
            Link>
          div>
        div>
      div>
    div>
  )
}

UIは以下のようなものになります。
sign in image

特に押さえておきたい部分はuseFormのonSubmitの処理とhandleGitHubSignInになります。

ここでauthClientを通じて、認証用APIにアクセスします。
また、特に注意が必要なのはauthClient.signIn.socialcallbackUrlの設定が必要なことです。

これをアプリケーション側のURLにしないと、ConvexのSITE URLに認証後リダイレクトされていまします
ですので、ここは忘れずに設定しておいてください。

  const form = useForm({
    defaultValues: {
      email: '',
      password: '',
      error: '',
    },
    onSubmit: ({ value, formApi }) => {
      formApi.setFieldValue('error', '')

      startTransition(async () => {
        try {
          await authClient.signIn.email(
            {
              email: value.email,
              password: value.password,
            },
            {
              onSuccess: () => {
                navigate({ to: "https://zenn.dev/" })
              },
              onError: (ctx) => {
                formApi.setFieldValue('error', ctx.error.message || 'ログインに失敗しました')
              },
            },
          )
        } catch (_err) {
          formApi.setFieldValue('error', '予期しないエラーが発生しました')
        }
      })
    },
  })

  const handleGitHubSignIn = () => {
    form.setFieldValue('error', '')

    startTransition(async () => {
      try {
        await authClient.signIn.social({
          provider: 'github',
          callbackURL: 'http://localhost:3000/',
        })
      } catch (_err) {
        form.setFieldValue('error', 'GitHub認証に失敗しました')
      }
    })
  }

SignUp ページの作成

こちらのソースは以下です。

https://github.com/sc30gsw/tanstack-convex-better-auth-example/blob/main/src/routes/auth/sign-up.tsx

また、全体像は以下のようになります。
こちらも重要な箇所や注意点はsign-in.tsxと同じになります。

routes/auth/sign-up.tsx

import { useForm } from '@tanstack/react-form'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { AlertCircle, Github, Loader2, Lock, Mail, User } from 'lucide-react'
import { useTransition } from 'react'
import { authClient } from '~/lib/auth-client'

export const Route = createFileRoute('/auth/sign-up')({
  component: SignUp,
})

function SignUp() {
  const navigate = useNavigate()
  const [isPending, startTransition] = useTransition()

  const form = useForm({
    defaultValues: {
      name: '',
      email: '',
      password: '',
      error: '',
    },
    onSubmit: ({ value, formApi }) => {
      formApi.setFieldValue('error', '')

      startTransition(async () => {
        try {
          await authClient.signUp.email(
            {
              name: value.name,
              email: value.email,
              password: value.password,
            },
            {
              onSuccess: () => {
                
                navigate({ to: "https://zenn.dev/" })
              },
              onError: (ctx) => {
                formApi.setFieldValue('error', ctx.error.message || '登録に失敗しました')
              },
            },
          )
        } catch (_err) {
          formApi.setFieldValue('error', '予期しないエラーが発生しました')
        }
      })
    },
  })

  const handleGitHubSignUp = () => {
    form.setFieldValue('error', '')

    startTransition(async () => {
      try {
        await authClient.signIn.social({
          provider: 'github',
          callbackURL: 'http://localhost:3000/',
        })
      } catch (_err) {
        form.setFieldValue('error', 'GitHub認証に失敗しました')
      }
    })
  }

  return (
    div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4 dark:from-slate-900 dark:to-slate-800">
      div className="w-full max-w-md">
        div className="mb-8 text-center">
          h1 className="font-bold text-3xl text-slate-900 dark:text-slate-50">新規登録h1>
          p className="mt-2 text-slate-600 text-sm dark:text-slate-400">
            アカウントを作成してください
          p>
        div>

        div className="rounded-lg bg-white p-8 shadow-lg dark:bg-slate-800">
          {form.state.values.error && (
            div className="mb-4 flex items-center gap-2 rounded-md bg-red-50 p-3 text-red-800 text-sm dark:bg-red-900/20 dark:text-red-400">
              AlertCircle className="h-4 w-4 flex-shrink-0" />
              span>{form.state.values.error}span>
            div>
          )}

          form
            onSubmit={(e) => {
              e.preventDefault()
              e.stopPropagation()
              form.handleSubmit()
            }}
            className="space-y-4"
          >
            form.Field
              name="name"
              validators={{
                onChange: ({ value }) => (!value ? 'お名前は必須です' : undefined),
              }}
            >
              {(field) => (
                div>
                  label
                    htmlFor={field.name}
                    className="mb-1 block font-medium text-slate-700 text-sm dark:text-slate-300"
                  >
                    お名前
                  label>
                  div className="relative">
                    User className="absolute top-3 left-3 h-5 w-5 text-slate-400" />
                    input
                      id={field.name}
                      name={field.name}
                      type="text"
                      value={field.state.value}
                      onBlur={field.handleBlur}
                      onChange={(e) => field.handleChange(e.target.value)}
                      required
                      className="w-full rounded-md border border-slate-300 py-2 pr-3 pl-10 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
                      placeholder="山田 太郎"
                      disabled={isPending}
                    />
                  div>
                  {field.state.meta.errors.length > 0 && (
                    p className="mt-1 text-red-600 text-xs dark:text-red-400">
                      {field.state.meta.errors[0]}
                    p>
                  )}
                div>
              )}
            form.Field>

            form.Field
              name="email"
              validators={{
                onChange: ({ value }) =>
                  !value
                    ? 'メールアドレスは必須です'
                    : !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
                      ? 'メールアドレスの形式が正しくありません'
                      : undefined,
              }}
            >
              {(field) => (
                div>
                  label
                    htmlFor={field.name}
                    className="mb-1 block font-medium text-slate-700 text-sm dark:text-slate-300"
                  >
                    メールアドレス
                  label>
                  div className="relative">
                    Mail className="absolute top-3 left-3 h-5 w-5 text-slate-400" />
                    input
                      id={field.name}
                      name={field.name}
                      type="email"
                      value={field.state.value}
                      onBlur={field.handleBlur}
                      onChange={(e) => field.handleChange(e.target.value)}
                      required
                      className="w-full rounded-md border border-slate-300 py-2 pr-3 pl-10 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
                      placeholder="[email protected]"
                      disabled={isPending}
                    />
                  div>
                  {field.state.meta.errors.length > 0 && (
                    p className="mt-1 text-red-600 text-xs dark:text-red-400">
                      {field.state.meta.errors[0]}
                    p>
                  )}
                div>
              )}
            form.Field>

            form.Field
              name="password"
              validators={{
                onChange: ({ value }) =>
                  !value
                    ? 'パスワードは必須です'
                    : value.length  8
                      ? 'パスワードは8文字以上で入力してください'
                      : undefined,
              }}
            >
              {(field) => (
                div>
                  label
                    htmlFor={field.name}
                    className="mb-1 block font-medium text-slate-700 text-sm dark:text-slate-300"
                  >
                    パスワード
                  label>
                  div className="relative">
                    Lock className="absolute top-3 left-3 h-5 w-5 text-slate-400" />
                    input
                      id={field.name}
                      name={field.name}
                      type="password"
                      value={field.state.value}
                      onBlur={field.handleBlur}
                      onChange={(e) => field.handleChange(e.target.value)}
                      required
                      minLength={8}
                      className="w-full rounded-md border border-slate-300 py-2 pr-3 pl-10 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
                      placeholder="8文字以上"
                      disabled={isPending}
                    />
                  div>
                  {field.state.meta.errors.length > 0 && (
                    p className="mt-1 text-red-600 text-xs dark:text-red-400">
                      {field.state.meta.errors[0]}
                    p>
                  )}
                  {field.state.meta.errors.length === 0 && (
                    p className="mt-1 text-slate-500 text-xs dark:text-slate-400">
                      パスワードは8文字以上で入力してください
                    p>
                  )}
                div>
              )}
            form.Field>

            button
              type="submit"
              disabled={isPending}
              className="flex w-full items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-slate-800"
            >
              {isPending ? (
                >
                  Loader2 className="h-5 w-5 animate-spin" />
                  登録中...
                >
              ) : (
                'アカウント作成'
              )}
            button>
          form>

          div className="my-6 flex items-center">
            div className="h-px flex-1 bg-slate-300 dark:bg-slate-600" />
            span className="px-4 text-slate-500 text-sm dark:text-slate-400">またはspan>
            div className="h-px flex-1 bg-slate-300 dark:bg-slate-600" />
          div>

          button
            type="button"
            onClick={handleGitHubSignUp}
            disabled={isPending}
            className="flex w-full items-center justify-center gap-2 rounded-md border-2 border-slate-300 bg-white px-4 py-2 font-medium text-slate-700 transition-colors hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200 dark:focus:ring-offset-slate-800 dark:hover:bg-slate-600"
          >
            Github className="h-5 w-5" />
            GitHubで登録
          button>

          div className="mt-6 text-center text-slate-600 text-sm dark:text-slate-400">
            既にアカウントをお持ちですか?{' '}
            Link
              to="/auth/sign-in"
              className="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
            >
              ログイン
            Link>
          div>
        div>
      div>
    div>
  )
}

UIは以下のようになってます。
sign up image

SignOut ページの作成

こちらのソースは以下です。

https://github.com/sc30gsw/tanstack-convex-better-auth-example/blob/main/src/routes/auth/sign-out.tsx

こちらはUIの実装はありませんが、TanStack RouterのbeforeLoadという機能を用いてsignOut機能を実装しています。

https://tanstack.com/router/v1/docs/framework/react/guide/authenticated-routes

authClient.signOut()実施後、signInページにリダイレクトするというものになっております。

routes/sign-out.tsx

import { createFileRoute, redirect } from '@tanstack/react-router'
import { authClient } from '~/lib/auth-client'

export const Route = createFileRoute('/auth/sign-out')({
  beforeLoad: async ({ preload }) => {
    if (preload) {
      return
    }

    await authClient.signOut()

    throw redirect({
      to: '/auth/sign-in',
    })
  },
})

UI上では、以下のように使用することができ、これだけで、signOutが実施されます。

import { useNavigate } from '@tanstack/react-router'

export function SignOutButton() {
  const navigate = useNavigate()

  return (
    button
      type="button"
      onClick={() => navigate({ to: '/auth/sign-out' })}
      className="cursor-pointer text-blue-600 underline hover:no-underline"
    >
      Sign out
    button>
  )
}

ここまでで認証機能自体の作成は完了しました。
そのため、この時点で、Email/Passwordの認証は機能します。
ただ、まだGitHub OAuthの機能は動作しないので、次項でその部分の処理と設定を行っていきます。

4. GitHub OAuth 設定実装

こちらもBetter Authドキュメントに従い、実施してきます。

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

まずは、GitHub Appの作成からです。

GitHub Appの作成

アプリを作成したら、Client IDClient secretsを取得します。

GitHub OAuth App 1

また、Homepage URLAuthorization callback URLも設定します。

ここでもCONVEX_SITE_URLに設定したConvexのSITE URLを指定するにしてください。

つまりそれぞれ、以下の値を設定することになります。

  • Homepage URL: ConvexのSITE URL
  • Authorization callback URL: ConvexのSITE URL/api/auth/callback/github

GitHub OAuth App 2

Convexに環境変数追加

ここではConvex側にのみ環境変数を追加します。
OAuth処理はConvexが持つことになるため、ローカル環境変数の設定は不要です。

以下のコマンドで環境変数を設定します。

fish

bun x convex env set GITHUB_CLIENT_ID your-client-id
bun x convex env set GITHUB_CLIENT_SECRET your-client-secret

Convex コードの実装

次に、Convex側にGitHub OAuth認証が実施できるよう処理・設定を記述していきます。

各ファイルに以下のように記述を加えていきます。

convex/auth.config.ts

export default {
  providers: [
    {
      domain: process.env.CONVEX_SITE_URL,
      applicationID: "convex",
    },
  ],
+  socialProviders: {
+    github: {
+      clientId: process.env.GITHUB_CLIENT_ID!,
+      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
+    }
  },
};

こちらはConvexのSITE URLとアプリケーション側のURLが異なるため、CORSの設定をしておきます。
これがないと、TanStack Startで認証リクエストした際にCORSのエラーが発生することになります。
※ 追加でconvex/auth.tsにも設定を加えないとCORSが解消できないので、これだけで解決できるというわけではありません。

convex/http.ts

import { httpRouter } from "convex/server";
import { authComponent, createAuth } from "./auth";

const http = httpRouter();

+ authComponent.registerRoutes(http, createAuth, { cors: true });

export default http;

こちらでは、socialProvidersの設定を追加するとともに、クロスドメイン時のCookie送信設定も追加してきます。
ここまででCORSのエラーが解消できます。

また、baseUrlもConvexのSITE URLに修正し、GitHub Appの設定と合わせておきます。
このようにすることで、以下のようなフローができます。

  1. 画面からGitHub OAuth認証リクエスト(フロントエンドではcallbackUrlにアプリケーションのURLを指定してリクエスト)
  2. ConvexがBetter Authエンドポイントにリクエストし、認証実施
  3. GitHub OAuth認証をし、GitHub Appで指定したAuthorization callback URL(ConvexのSITE URL)にリダイレクト
  4. Convexで認証結果のレスポンスを受け取る
  5. フロントエンドで指定したcallbacksUrlにリダイレクト

この設定により、GitHub OAuth 認証フローとフロントエンドの画面遷移を 一貫した流れとして扱える ようになります。ユーザーは GitHub で認証した後、自動的にアプリケーションの指定した画面に戻り、そのままログイン済みの状態で利用を開始できます。

convex/auth.ts

import { createClient, type GenericCtx } from "@convex-dev/better-auth";
import { convex } from "@convex-dev/better-auth/plugins";
import { components } from "./_generated/api";
import { DataModel } from "./_generated/dataModel";
import { action, query } from "./_generated/server";
import { betterAuth } from "better-auth";

const siteUrl = process.env.SITE_URL!;

// The component client has methods needed for integrating Convex with Better Auth,
// as well as helper methods for general use.
export const authComponent = createClient(components.betterAuth);

export const createAuth = (
  ctx: GenericCtxDataModel>,
    { optionsOnly } = { optionsOnly: false },
  ) => {
  return betterAuth({
    
    
    logger: {
      disabled: optionsOnly,
    },
+    trustedOrigins: [
+      'http://localhost:3000',
+      process.env.CONVEX_SITE_URL!,
+    ],
-   baseURL: siteUrl,
+   baseURL: process.env.CONVEX_SITE_URL!,
    database: authComponent.adapter(ctx),
+    socialProviders: {
+      github: {
+        clientId: process.env.GITHUB_CLIENT_ID!,
+        clientSecret: process.env.GITHUB_CLIENT_SECRET!,
+      }
+    },
    
    emailAndPassword: {
      enabled: true,
      requireEmailVerification: false,
    },
    plugins: [
      
      convex(),
    ],
+    
+    
+    advanced: {
+      
+      sessionMaxAge: 24 * 60 * 60,
+     
+     useSecureCookies: process.env.NODE_ENV === "production",
+
+      
+      
+      defaultCookieAttributes: {
+        sameSite: process.env.NODE_ENV === "development" ? "lax" : "none",
+      },
+    },
  });
};

// Example function for getting the current user
// Feel free to edit, omit, etc.
export const getCurrentUser = query({
  args: {},
  handler: async (ctx) => {
    return authComponent.getAuthUser(ctx);
  },
});

これで、以下のGifのようにGitHub OAuthがうまく動作するはずです。

github oauth test

5. Session取得処理実装

ここまでで、認証処理自体は完全に実装できたので、sessionからユーザー情報を取得する処理を記述してみましょう。

以下のように、authClient.useSession()を呼び出すことで、Sessionに含まれているユーザー情報を取得することができます。

import { createFileRoute } from '@tanstack/react-router'
import { authClient } from '~/lib/auth-client'

export const Route = createFileRoute("https://zenn.dev/")({
  component: Home,
})

function Home() {
  const { data: session } = authClient.useSession()

  return (
    p>Welcome {session?.user.name}!p>
  )
}

6. Authorized Component 作成

最後に認証・未認証時の画面制御処理を加えて実装を完了しようと思います。

今回は以下のコンポーネントを使用してこの機能を実現しています。

components/authorized.tsx

import { useNavigate } from '@tanstack/react-router'
import { useEffect } from 'react'
import { Loader } from '~/components/loader'
import { authClient } from '~/lib/auth-client'

const PUBLIC_ROUTES = ['/auth/sign-in', '/auth/sign-up']

export function Authorized({ children }: Record'children', React.ReactNode>) {
  const navigate = useNavigate()
  const { data: session, isPending } = authClient.useSession()

  useEffect(() => {
    if (!isPending && !session?.user) {
      navigate({
        to: '/auth/sign-in',
        search: {
          redirect: location.pathname,
        },
      })
    } else if (session?.user && PUBLIC_ROUTES.includes(location.pathname)) {
      
      navigate({
        to: "https://zenn.dev/",
      })
    }
  }, [session, isPending, navigate])

  if (isPending) {
    return Loader />
  }

  return children
}

そのため、Home画面であれば、以下のように使用することができます。

routes/index.tsx

export const Route = createFileRoute("https://zenn.dev/")({
  component: RootComponent,
})

function RootComponent() {
  return (
    Authorized>
      Home />
    Authorized>
  )
}

また、/auth/sign-in.tsx/auth/sing-up.tsxなど/authというRouteに複数の子Routeがある場合、TanSack Routerではroute.tsxを使用できます。
これは、共通Layoutなどを記述するためのものです。

https://tanstack.com/router/v1/docs/framework/react/routing/file-naming-conventions

今回であれば、以下のようにすることができます。
これで、認証・未認証時のリダレクト機能が実装できます。

routes/auth/route.tsx

import { createFileRoute, Outlet } from '@tanstack/react-router'
import { Authorized } from '~/components/authorized'

export const Route = createFileRoute('/auth')({
  component: RouteComponent,
})

function RouteComponent() {
  return (
    Authorized>
      Outlet />
    Authorized>
  )
}

ただ、このようなケースの場合、私としてはTanStack RouterのbeforeLoadを使うべきと考えています。

https://tanstack.com/router/v1/docs/framework/react/guide/authenticated-routes

ただ、現状、私の環境では動作が安定せず、TanStack Startの依存関係でエラーがでるなどしたため、こちらの実装をしております。
これらが安定し、TanStack Startが真に安定版となった際は、beforeLoadで行追うと思います。

https://github.com/TanStack/router/issues/5196

今回、Convex・TanStack Start・Better Authと比較的新しい技術で実装しましたが、個人的には情報が少なくかなり苦戦したというのが正直なところなので、この記事が参考になる方がいたら幸いです!

https://tanstack.com/start/latest

https://docs.convex.dev/home

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

https://tanstack.com/form/latest

https://tanstack.com/router/v1/docs/framework/react/guide/authenticated-routes

https://github.com/TanStack/router/issues/5196

https://github.com/sc30gsw/tanstack-convex-better-auth-example



Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -