Header image

クラッソーネの開発者がエンジニアリングに関することもそうでないことも綴っています!

Remix、Lucia Auth(v3)、Cloudflare D1を使ってGoogleログインを実装してみた

Remix、Lucia Auth(v3)、Cloudflare D1を使ってGoogleログインを実装してみた

こんにちは!最近は韓国語の勉強をしている町田です。
次回のブログ執筆までには単純な文の読み書きができているといいな〜と思っているこの頃です。
korean-language-books

さて、今回のテックブログでは、RemixLucia Auth(v3)Cloudflare D1でGoogleログインを実装するプロセスについて紹介します。

Remixでの認証はremix-authを使う実装をよく見かけますが、Lucia Authを使った実装方法を紹介する記事はあまり見かけません。
そこで、自身で実装してみたので、今後RemixとLucia Authで認証を実装したい方の参考になれば幸いです。
(執筆時点ではLucia Authのv3での実装例が少ないため、その点も含めて参考になることを願っています。)

※ Remix、Lucia Auth、およびCloudflare D1についての詳細な説明はこの記事では行いませんので、公式ドキュメントや他の参考記事をご参照ください。
※ Cloudflareのアカウントは既に作成済みの前提で進めます。

1. プロジェクトのセットアップ

まずは、Cloudflareで動かすRemixの新しいプロジェクトを作成します。

npm create cloudflare@latest my-remix-app -- --framework=remix
cd my-remix-app

次に、Lucia Authとその依存関係をインストールします。
今回はCloudflare D1をデータベースとして使うので、sqlite用のアダプターもインストールします。
また、Google認証の実装にarcticを使うので、合わせてインストールします。

npm install lucia @lucia-auth/adapter-sqlite arctic

Cloudflare D1に接続するためにdrizzleを使用するので、こちらもインストールします。

npm install drizzle-orm
npm install -D drizzle-kit

※ drizzle-ormがインストールできない場合は、reactとreact-domをv18.3.0以上にしてから再度インストールを試してください。

2. Cloudflare D1を作成

以下のコマンドを実行して、Cloudflare D1を作成します。

npx wrangler d1 create my-remix-app-d1

[[d1_databases]]の部分をwrangler.tomlに貼り付けます。

-------------------------------------------------------
✅ Successfully created DB 'my-remix-app-d1' in region APAC
Created your new D1 database.

[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "my-remix-app-d1"
database_id = "your-database-id"

3. Cloudflare D1のスキーマを定義

luciaはセッションを発行するためのテーブルと、セッションに紐づくユーザテーブルが必要なので定義します。
app/schema.tsにスキーマファイルを作成します。

import { sql } from 'drizzle-orm';
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  email: text('email').notNull(),
  googleProfileId: text('google_profile_id').notNull().unique(),
  iconUrl: text('icon_url'),
  displayName: text('display_name').notNull(),
  createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
  updatedAt: integer('updated_at', { mode: 'timestamp' }).default(sql`(unixepoch())`),
});

export const sessions = sqliteTable('sessions', {
  id: integer('id').primaryKey().notNull(),
  expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
  userId: text('user_id').references(() => users.id),
});

4. Cloudflare D1にマイグレーションを適用

ディレクトリのトップにdrizzle.config.tsを作成します。
Cloudflare D1にマイグレーションを適用するための設定をします。

import type { Config } from "drizzle-kit";

export default {
  dialect: 'sqlite',
  schema: "./app/schema.ts",
  out: "./migrations",
  driver: "d1",
  dbCredentials: {
    wranglerConfigPath: "wrangler.toml",
    dbName: 'my-remix-app-d1',
  },
} satisfies Config;

マイグレーションファイルを生成します。

npx drizzle-kit generate

以下のようなログが出ていると成功です。

No config path provided, using default 'drizzle.config.ts'
Reading config file '/Users/hoge/my-remix-app/drizzle.config.ts'
2 tables
sessions 3 columns 0 indexes 1 fks
users 7 columns 1 indexes 0 fks

[] Your SQL migration file ➜ migrations/0000_naive_turbo.sql 🚀

ローカルで動作するCloudflare D1に、マイグレーションを適用します。

npx wrangler d1 migrations apply my-remix-app-d1 --local

マイグレーションの適用に成功すると、以下のようなログが出ます。

-------------------------------------------------------
Migrations to be applied:
┌──────────────────────┐
│ name                 │
├──────────────────────┤
│ 0000_naive_turbo.sql │
└──────────────────────┘
✔ About to apply 1 migration(s)
Your database may not be available to serve requests during the migration, continue? … yes
🌀 Executing on local database my-remix-app-d1 (your-database-id) from .wrangler/state/v3/d1:
🌀 To execute on your remote database, add a --remote flag to your wrangler command.
┌──────────────────────┬────────┐
│ name                 │ status │
├──────────────────────┼────────┤
│ 0000_naive_turbo.sql │ ✅       │
└──────────────────────┴────────┘

5. Google OAuth クレデンシャルの取得

Google Cloud Consoleにアクセスし、新しいプロジェクトを作成してOAuth 2.0クライアントIDを設定します。
リダイレクトURIを設定し、クライアントIDとクライアントシークレットを取得します。
以下のリンクを参考にしてください。
参考:https://developers.google.com/identity/protocols/oauth2/web-server#creatingcred

  • 承認済みのJavaScript生成元:http://localhost:5173
  • 承認済みのリダイレクトURI:http://localhost:5173/google/callback

※ 承認済みのリダイレクトURIは後ほど実装する認証処理でのコールバックを受け取ります。

6. 環境変数の設定

.dev.varsファイルを作成し、以下を追加します。
OAuthクライアント作成時に発行されたIDとSECRETは各自で貼り付けてください。

NODE_ENV="development"
GOOGLE_AUTH_CLIENT_ID="your-google-client-id"
GOOGLE_AUTH_CLIENT_SECRET="your-google-client-secret"
GOOGLE_AUTH_CALLBACK_URL="http://localhost:5173/auth/google/callback"
SESSION_SECRET="se3cret"

この時点で、バインディングで環境変数やCloudflare D1を扱えるように以下のコマンドを実行してください。

npx wrangler types

7. LuciaAuthの設定

app/lucia.tsというファイルを作成し、Lucia Authの設定を記述します。
Lucia Authで扱うセッションやクッキーの設定は、以下のリンクを参考に各自で設定してください。
参考:https://lucia-auth.com/basics/configuration

import { Lucia, TimeSpan } from "lucia";
import { D1Adapter } from "@lucia-auth/adapter-sqlite";
import { AppLoadContext } from "@remix-run/cloudflare";

let lucia: Lucia | null = null;
export function initializeLucia(context: AppLoadContext) {
  if (lucia) {
    return lucia;
  };
  const { DB, NODE_ENV } = context.cloudflare.env;
	const adapter = new D1Adapter(DB, {
    // ユーザーを保存するテーブル名を指定
		user: "users",
    // セッションを保存するテーブル名を指定
		session: "sessions"
	});
  lucia = new Lucia(adapter, {
    // セッションの有効期限
    sessionExpiresIn: new TimeSpan(2, "w"),
    // クッキーの設定
    sessionCookie: {
      name: "session",
      attributes: {
        secure: NODE_ENV === "production",
        sameSite: "lax",
      }
    },
  });
	return lucia;
};

declare module "lucia" {
	interface Register {
		Auth: ReturnType<typeof initializeLucia>;
	}
};

8. 認証ルートの作成

app/auth/google.tsファイルを作成し、Googleログインを実装するためにarcticから提供されるGoogleクラスを初期化します。

import { AppLoadContext } from "@remix-run/server-runtime";
import { Google } from "arctic";

export interface Profile {
  sub: string;
  email: string;
  email_verified: boolean;
  name: string;
  picture: string;
  given_name: string;
  locale: string;
}

let google: Google | null = null;
export const initializeArcticGoogle = (context: AppLoadContext) => {
  if (google) {
    return google;
  }
  const {
    GOOGLE_AUTH_CLIENT_ID: clientId,
    GOOGLE_AUTH_CLIENT_SECRET: clientSecret,
    GOOGLE_AUTH_CALLBACK_URL: redirectURI,
  } = context.cloudflare.env;
  google = new Google(clientId, clientSecret, redirectURI);
  return google;
};

次に、app/routes/auth.google.tsxを作成し、Googleログインのエンドポイントを設定します。

import { ActionFunctionArgs } from '@remix-run/cloudflare';
import { generateCodeVerifier, generateState } from "arctic";
import { serializeCookie } from "oslo/cookie";
import { initializeArcticGoogle } from '~/auth/google';

export const action = async ({ context }: ActionFunctionArgs) => {
  const state = generateState();
  const codeVerifier = generateCodeVerifier();
  const google = initializeArcticGoogle(context);

  const url: URL = await google.createAuthorizationURL(state, codeVerifier, {
    scopes: ["profile", "email"]
  });

  return redirect(url.toString(), {
		headers: [
      [
        'Set-Cookie',
        serializeCookie("state", state, {
          httpOnly: true,
          secure: context.cloudflare.env.NODE_ENV === "production",
          maxAge: 60 * 10,
          path: "/"
        })
      ],
      [
        'Set-Cookie',
        serializeCookie("code_verifier", codeVerifier, {
          httpOnly: true,
          secure: context.cloudflare.env.NODE_ENV === "production",
          maxAge: 60 * 10,
          path: "/"
        })
      ]
    ]
	});
};

export default function Empty() {
  return null;
};

9. 認証コールバックの作成

app/routes/auth.google.callback.tsxを作成し、認証コールバック時の処理を書きます。
ログインに成功したら、セッションとクッキーを発行し、トップページにリダイレクトするようにします。

import { LoaderFunctionArgs, redirect } from '@remix-run/cloudflare';
import { OAuth2RequestError } from 'arctic';
import { eq } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/d1';
import { parseCookies } from "oslo/cookie";
import { Profile, initializeArcticGoogle } from '~/auth/google';
import { initializeLucia } from '~/auth/lucia';
import { users } from '~/schema';

export const loader = async ({ request, context }: LoaderFunctionArgs) => {
  const cookies = parseCookies(request.headers.get("Cookie") ?? "");
	const storedState = cookies.get("state") ?? null;
  const storedCodeVerifier = cookies.get("code_verifier") ?? null;

	const url = new URL(request.url);
  const code = url.searchParams.get("code");
	const state = url.searchParams.get("state");

	if (!code || !storedState || !storedCodeVerifier || state !== storedState) {
		return new Response(null, {
			status: 400
		});
	};

	try {
    const google = initializeArcticGoogle(context);
		const tokens = await google.validateAuthorizationCode(code, storedCodeVerifier);
		const response = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
      headers: {
        Authorization: `Bearer ${tokens.accessToken}`
      }
    });
    const profile = await response.json() as Profile;
    const lucia = initializeLucia(context);
    const db = context.cloudflare.env.DB;
    const existsUser = await drizzle(db).select().from(users).where(eq(users.googleProfileId, profile.sub)).get();

    if (existsUser) {
      const session = await lucia.createSession(existsUser.id.toString(), {});
      const sessionCookie = lucia.createSessionCookie(session.id);
      return redirect('/', {
        headers: [
          ["Set-Cookie", sessionCookie.serialize()]
        ],
      });
    };

    const [user] = await drizzle(db).insert(users).values({
      email: profile.email,
      googleProfileId: profile.sub,
      iconUrl: profile.picture,
      displayName: profile.name
    }).returning();

		const session = await lucia.createSession(user.id.toString(), {});
		const sessionCookie = lucia.createSessionCookie(session.id);
    return redirect('/', {
      headers: [
        ["Set-Cookie", sessionCookie.serialize()]
      ],
    });
	} catch (e) {
		console.error(e);
		if (e instanceof OAuth2RequestError) {
			return redirect('/login');
		}
		return redirect('/login');
	};
};

export default function Empty() {
  return null;
};

10. ログイン画面の作成

app/routes/login.tsファイルを作成し、ボタンのみの簡易的なログイン画面を作成します。
ログインページに遷移する際に、既にログインしている状態であればトップページにリダイレクトするようにします。

import { LoaderFunctionArgs, redirect } from "@remix-run/cloudflare";
import { Form } from "@remix-run/react";
import { initializeLucia } from "~/auth/lucia";

export const loader = async ({ context, request }: LoaderFunctionArgs) => {
  const lucia = initializeLucia(context);
  const cookie = request.headers.get("Cookie");
  if (!cookie) {
    return { user: null, session: null };
  }
  const sessionId = lucia.readSessionCookie(cookie);
  if (!sessionId) {
    return { user: null, session: null };
  };

  const { user, session } = await lucia.validateSession(sessionId);
  if (user) {
    return redirect("/");
  };

  return { user, session };
};

export default function Login() {
  return (
    <Form method="post" action="/auth/google">
      <button>
        <span>Login with Google</span>
      </button>
    </Form>
  )
};

これで、ユーザーがGoogleログインボタンをクリックすることで認証フローを開始できるようにしました。
実際にログインが成功すると、トップページにリダイレクトされます。
また、/loginのページに遷移してもトップページにリダイレクトされることを確認してください。

まとめ

Remix-authよりも柔軟に認証・認可の開発を進めたい場合は、Luciaに加えて、osloやarcticなどのOAuth2.0ライブラリや認証周りのユーティリティを合わせて実装するのがおすすめです。

これらのプロセスを通して、RemixとLucia Authを組み合わせてGoogleログインを実装する方法を解説しました。この記事が、今後RemixとLucia Authで認証を実装したい方の参考になれば幸いです。