🔐

React Router v7(SPA) × HonoでGoogleログインを実装し、ユーザー情報をDBに保存する

に公開

はじめに

最近、Webシステムの開発を Hono + React Router v7(SPA) のフルスタック TypeScript 構成で進めています。
その中で、ユーザー認証を Google ログイン で実装したいと思い調べてみたのですが、

  • 認証後のユーザー情報を DB に保存して、次回以降も同じアカウントとして扱う
  • フロントエンドからサインインを開始し、コールバックも SPA 側で受け取る
  • 認証後はどのページからでもログイン中のユーザー情報を取得できるようにする

といった情報がなかなか見つかりませんでした。
そこで今回は、私が実際に構築した手順をもとに、その仕組みと実装の流れを解説します。

参考にさせていただいた記事

プロジェクトの構築から Google ログイン認証の追加までの部分は、以下の2つの記事を大いに参考にさせていただきました。

本記事は、これらの記事の内容を踏まえたうえで、その先の「認証情報をDBに保存し、ログイン状態を共有できるようにする」部分を中心に解説しています。

フロントエンド(React Router v7)の実装

まずは、シンプルにログインボタンを設置します。

<Button  
  onClick={handleSignIn}  
  variant='outline'  
  className='w-full h-12 border-2 border-primary hover:bg-primary/5 bg-transparent'  
>  
  Googleでサインイン  
</Button>

handleSignInの中身は以下のとおりです。

frontend/services/auth.ts
import { authConfigManager, signIn } from '@hono/auth-js/react';

export const handleSignIn = async () => {  
  authConfigManager.setConfig({  
    baseUrl: import.meta.env.VITE_API_ORIGIN,  
    credentials: 'include',  
  });  
  await signIn('google', {  
    callbackUrl: import.meta.env.VITE_CLIENT_ORIGIN,  
  });  
};

ここで .envファイルには、VITE_API_ORIGINにバックエンドのオリジンを、VITE_CLIENT_ORIGINにフロントエンドのオリジンを設定します。
Vite では環境変数をフロントエンドから参照するためにVITE_プレフィックス が必要です。
ローカル開発環境の場合は、以下のような設定になると思います。

.env.local
VITE_API_ORIGIN='http://localhost:8787'  
VITE_CLIENT_ORIGIN='http://localhost:5173'

callbackUrlには、認証後にリダイレクトさせたいURLを指定します。
また、credentials: 'include'が重要なポイントで、これは クロスオリジン環境でも Cookie などの認証情報を常に送受信する という指定です。
これを指定しない場合、デフォルトは'same-origin'となり、認証情報が正しく送信されません。
ただし、このままだとセキュリティ上の問題があるため、後述のバックエンド側でAccess-Control-Allow-Originを正しく指定します。

バックエンド(Hono)の実装

Hono のindex.tsでは、認証関連の設定を以下のように記述します。

backend/index.ts
app.use(  
  '*',  
  cors({  
    origin: (_, c) => c.env.VITE_CLIENT_ORIGIN,  
    credentials: true,  
  }),  
);  
app.use(  
  initAuthConfig(c => ({  
    secret: c.env.AUTH_SECRET,  
    providers: [  
      Google({  
        clientId: c.env.GOOGLE_ID,  
        clientSecret: c.env.GOOGLE_SECRET,  
      }),  
    ],  
    callbacks: {  
      signIn: async ({ user, account }) => {  
        if (!user || !account) {  
          return false;  
        }  
        // ここでDBに保存する処理を行う
        const useCases = c.get('useCases') as UseCases;  
        await useCases.user.signIn(user, account, AuthProvider.Google);  
        return true;
      },  
      redirect: async ({ url }) => url,  
      session: ({ session, token }) => {  
        if (token.sub) {  
          session.user.id = token.sub;  
        }  
        return session;  
      },  
    },  
  })),  
);  
app.use('/auth/*', authHandler());  
app.use('*', verifyAuth());

CORS 設定

先ほど述べたように、CORS 設定でoriginにフロントエンドのオリジンを指定することで、レスポンスヘッダにAccess-Control-Allow-Origin: {VITE_CLIENT_ORIGIN}が付与されます。
さらにcredentials: trueを指定することでAccess-Control-Allow-Credentials: trueが追加され、フロントエンド側のcredentials: 'include'を許可します。

認証時のコールバック

  • callbacks.signIn
    プロバイダ(この場合は Google)での認証後に実行される関数です。
    ここでユーザー情報を DB に保存するなどの処理を行うことができます。
    戻り値としてtrueを返すと成功、falseを返すと失敗として扱われます。
    引数に何が入るかは後述します。

  • callbacks.redirect
    フロントエンドで指定したcallbackUrlが引数のparams.urlに渡ってきます。
    ここではそれをそのまま返すことで、認証後にcallbackUrlで指定した URL にリダイレクトさせています。

  • callbacks.session
    セッション情報を読み取るたびに実行される処理です。
    ここでは、ユーザーの識別に使う ID をセッション情報にセットしています。

DB へのユーザー情報の保存

ここからは、実際に DB にどのような情報を保存すれば、次回以降も同じユーザーとして認識できるのかを解説します。

まずは、先ほど登場したcallbacks.signIncallbacks.sessionにそれぞれどのような値が渡されるのかを整理しておきましょう。

callbacks.signIn の引数

signIn: async ({ user, account }) => {
  console.log(user, account);
...

userの中身

{
  id: '{ID}',
  name: '{アカウント名}',
  email: '{メールアドレス}',
  image: '{アカウント画像URL}'
} 

accountの中身

{
  access_token: '{トークン文字列}',
  expires_in: 3598,
  scope: 'https://www.googleapis.com/auth/userinfo.email openid https://www.googleapis.com/auth/userinfo.profile',
  token_type: 'bearer',
  id_token: '',
  expires_at: 1762594953,
  provider: 'google',
  type: 'oidc',
  providerAccountId: '{プロバイダーのアカウントID}'
}

callbacks.session の引数

session: ({ session, token }) => {
  console.log(session, token);
...

sessionの中身

{
  user: {
    name: '{アカウント名}',
    email: '{メールアドレス}',
    image: '{アカウント画像URL}'
  },
  expires: '{セッションの有効期限}'
}

tokenの中身

{
  name: '{アカウント名}',
  email: '{メールアドレス}',
  picture: '{アカウント画像URL}',
  sub: '{ID}',
  iat: 1762591355,
  exp: 1765183355,
  jti: '{JWT の一意ID}'
}

subは上記signInuser.idと一致します。

どんな点に気をつけるべきか

ここまで見てきた各種 JSON の中身から、次のような点が重要だと考えられます。

  • user.idセッションが変わるたびに変化するため、アカウントを一意に識別する値としては使えない。
  • 同様にメールアドレスも、ユーザーが変更した場合に値が変化する可能性がある。
    • Google の場合は変更できないものの、他のプロバイダーでは変更されることもあり得る。
  • アカウントを識別するために毎セッションで必ず一致する値account.providerAccountIdである。
  • ただし、providerAccountIdcallbacks.sessionの引数には渡ってこない。

つまり、次のような方針で設計するとよさそうです。

  1. ユーザーテーブルにuser.idaccount.providerAccountIdの両方を保持するカラムを用意する。
  2. サインイン時にはaccount.providerAccountIdをキーにして DB からユーザー情報を取得する。
  3. サインインのたびに、user.idは最新のものに更新する。
  4. ログイン中のユーザー情報を取得する際は、user.idを使って参照する。

ユーザーユースケースの実装

ここまでの点を踏まえて、ユーザーまわりの処理を担うユースケースを以下のように実装しました。

import type { User as AuthUser, Account } from '@auth/core/types';  
import { AuthProvider } from '@/enum';  
import type { User } from '@/models';  
import type { UserRepository } from '@/repositories/users';

export type UserUseCases = {  
  signIn(authUser: AuthUser, account: Account, provider: AuthProvider): Promise<User>;  
  findByUuid(uuid: User['uuid'], provider: AuthProvider): Promise<User | undefined>;  
};  
  
export const userUseCases = (repository: UserRepository): UserUseCases => ({  
  signIn: async (authUser, account, provider) => {
    // account.providerAccountId をキーに DB からユーザー情報を取得する
    const user = await repository.findByProviderId(account.providerAccountId, provider);
    if (user) {
      // user.id をはじめ変化しうる値は更新する
      return await repository.update(user.id, {
        uuid: authUser.id,
        displayName: authUser.name ?? '',
        avatarUrl: authUser.image,
      });
    } else {
      // 新しく登録する
      return await repository.create({
        avatarUrl: authUser.image,
        authProvider: account.provider,
        authExternalId: account.providerAccountId,
        uuid: authUser.id!,
        displayName: authUser.name ?? '',
      });
    }
  },
  findByUuid: async (uuid, provider) => {  
    return await repository.findByUuid(uuid, provider);  
  },  
});

DB へ保存したユーザー情報の取得

ログイン中のユーザー情報を取得するための API エンドポイントを追加します。

import { AuthProvider } from '@/enum';
import { Hono } from 'hono';
import type { SetUpEnv } from '@/middlewares';
import { ApiException } from '@/utils/exception';

const app = new Hono<SetUpEnv>().get('/me', async c => {
  const auth = c.get('authUser');
  const uuid = auth?.session.user?.id;
  if (!uuid) {
    throw new ApiException('Unauthorized', 401);
  }
  const user = await c.var.useCases.user.findByUuid(uuid, AuthProvider.Google);
  if (!user) {
    throw new ApiException('NotFound', 404);
  }
  return c.json(user);
});

export const userRoutes = app;

c.get('authUser')を使うことで、先ほどの session.userの情報をどの API ルートからでもコンテキスト経由で取得できます。
ただし、デフォルトではuser.idはセッション情報に含まれていません。
そのため、callbacks.session内でsession.user.id = token.sub;とすることで、ユーザーの ID をセッション情報に保持できるようにしています。

この処理はミドルウェアに切り出して、コンテキストに find したuserをセットしておくとより便利だと思います。興味のある方はぜひ実装してみてください。

フロントエンドで認証チェックを行う

ここでは、Hono の RPC 機能と、最近 React Router で stable になったMiddleware 機能を組み合わせて、先ほど作成した /me API にアクセスし、ログイン状態のチェックを行っていきます。

まずは、RPC を利用した API クライアントを定義しましょう。

frontend/services/api.ts
import { hc } from 'hono/client';
import type { AppType } from '@/index';

export const apiClient = hc<AppType>(import.meta.env.VITE_API_ORIGIN, {
  init: { credentials: 'include' },
});

ここで登場するAppTypeは、Hono の index.ts で定義した型を参照しています。
以下のようにエクスポートしておくことで、フロントエンド側から RPC の型を安全に利用できるようになります。

backend/index.ts
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _route = app.route('/users', userRoutes);

export type AppType = typeof _route;

export default app;

これでバックエンドとフロントエンドで型を共有できるようになりました。
続いて、認証チェックを行うための Middleware を定義していきます。

frontend/services/middleware.ts
import type { User } from '@/models';
import { createContext, type MiddlewareFunction } from 'react-router';
import { apiClient } from '~/services/api';

export const authUserContext = createContext<User | null>();

export const authMiddleware: MiddlewareFunction = async ({ context }) => {
  const response = await apiClient.api.users.me.$get();
  if (!response.ok) {
    context.set(authUserContext, null);
    // 必要に応じてここでログインページへのリダイレクト等の処理を入れるといいと思います
    return;
  }
  const user = await response.json();
  context.set(authUserContext, user);
};

Hono の RPC を今回初めて使いましたが、これは本当に便利ですね。
await response.json()した時点で型が自動で付与されるのには感動しました。

最後に、ログインユーザー情報が必要なページでローダーとミドルウェアを設定して、認証済みのユーザー情報を受け取れるようにします。

frontend/routes/index.tsx
export const clientMiddleware = [authMiddleware];

export const clientLoader = ({ context }: Route.ClientLoaderArgs) => {
  const authUser = context.get(authUserContext);
  return {
    authUser,
  };
};

export default function Index() {
  const { authUser } = useLoaderData<typeof clientLoader>();
  return (
    <AppContent>
      <Component authUser={authUser} />
    </AppContent>
  );
}

これで、フロントエンド側からログインユーザー情報を型安全に扱えるようになりました。

まとめ

今回は、React Router v7(SPA) と Hono を組み合わせて、Google ログインを実装し、認証情報を DB に保存・共有する仕組みを構築しました。

SPA 構成ではフロントエンドとバックエンドが分離しているため、Cookie の取り扱いや CORS の設定、認証後のリダイレクトなどに少し工夫が必要です。
しかし、Hono の RPC や @hono/auth-js、React Router の Middleware 等を組み合わせることで、シンプルかつ型安全に認証フローを完結させることができました。

本記事が、同じように SPA での認証設計に悩む方の参考になれば幸いです。

Discussion