Lucia＋SvelteKitでGoogle OAuth2.0ログイン機能を作ってみた

これは [ビジネスエンジニアリング株式会社（B-EN-G） Advent Calendar 2023](https://qiita.com/advent-calendar/2023/b-en-g) の記事です。

# はじめに

普段の私はUIやDBアクセス機能などの開発を担当しており、外部と連携した認証・認可のプロトコルにはあまり触れてきませんでした。
ところが最近、担当しているサービスの一つで、外部サービスとのアカウント連携を考える必要が出てきました。

これを機に勉強してみよう :pencil: ということで、まずは簡単にできそうなOAuth2.0によるログイン機能を作ってみました。

# 今回作るもの

OAuth2で外部サービスからユーザ情報を取得し、ログイン / サインインできるWebアプリを作ります。

https://lucia-auth.com/guidebook/github-oauth/sveltekit/

ありがたいことに、GitHubのOAuthサービスを利用した公式ガイドがあります:pray:
こちらを参考に実装していきます。

## 使うもの

### Svelte / SvelteKit

アプリケーションのフレームワークはSvelte / SvelteKitを使います。
シンプルなルールでサクサク書けてお気に入りです！

使用したバージョンは以下のとおりです。
- Svelte： `4.2.7`
- SvelteKit： `1.27.4`

https://kit.svelte.jp/

### Lucia

認証やセッション管理にはLuciaを使います。
使用したバージョンは `2.7.4` です。

https://lucia-auth.com/

SvelteKitで使えるライブラリには [Auth.js](https://authjs.dev/) などもあります。
個人的には、以下のメリットがあるLuciaのほうが初心者にはよさそうと感じました。
- ドキュメントとexamplesが充実している
- シンプルだが、慣れれば柔軟なセッション管理ができそう

一方、現状ではJWTの検証はできないようなので、トークンベース認証には向いていないかもしれません。
（この辺りは十分に理解できていません。OpenID ConnectのIDトークンのデコードならできるらしい…？）

### SQLite3

将来的にはOAuthではない通常のログイン機能やセッション管理機能も作りたいので、ユーザ情報の管理にはデータベースを利用します。
自分しか使わないものですし、今回はSQLite3で済ませました。

### Google Cloud Platform（GCP）

OAuthサービスプロバイダとしてGoogle Cloud Platform（GCP）を利用します。
他のサービスでも良いのですが、せっかくなので使用経験の少ないGCPを選びました。

# 作ってみる

実際に作ってみましょう:hammer_pick:

##  OAuth2.0を使えるようにする

まずは、GCPでOAuthサービスプロバイダの設定を行います。
詳細は割愛しますが、大まかな手順は以下のとおりです。

1. GCPのプロジェクトを作成
2. OAuth同意画面を作成
3. OAuthクライアントIDを作成

作成したOAuthクライアントの `クライアントID` と `クライアントシークレット` は手元に控えておきます。

具体的な作成手順は、以下の記事が参考になります。

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

## データベースの準備

Luciaでは、スキーマに以下3つのテーブルが必要です。
OAuthによるログインの場合、特にコードを書かなくてもLuciaが自動でSQLを実行してくれます。

:::note info
メールアドレスの検証など、ログイン処理をカスタマイズする場合は自力でSQLを実行する必要があります。
:::

### User table

ユーザ情報を格納するテーブルです。
ユーザのサインイン時にレコードがinsertされます。

Lucia公式の指定にGoogleから取得した情報を加えて、以下のカラムを定義します。

| カラム | 説明 |
| :--- | :--- |
| `id` | ユーザのID |
|`username` | Googleユーザの名前 |
| `email` | Googleユーザのメールアドレス |

<details><summary>SQL</summary>

```sql
create table user (
    id text not null primary key,
	username text not null
	email text,
);
```

</details>

### Session table

ログインユーザのセッション情報を格納するテーブルです。
セッションの作成時にレコードがinsertされ、セッションの無効化時にレコードがdeleteされます。

Lucia公式の指定通り、以下のカラムを定義します。

| カラム | 説明 |
| :--- | :--- |
| `id` | セッションのID |
| `userid` | ユーザのID（foreign key） |
| `active_expires` | セッションがアクティブ→アイドルになるまでの期限 |
| `idle_expires` | セッションがアイドル→無効になるまでの期限 |

<details><summary>SQL</summary>

```sql
create table user_session (
	id text not null primary key,
	user_id text not null,
	active_expires integer not null,
	idle_expires integer not null,
	foreign key (user_id) references user(id)
);
```

</details>

### Key table

認証プロバイダのID情報とアプリケーション内部のユーザIDを紐づけるためのテーブルです。
ユーザのサインイン時にレコードがinsertされます。

Lucia公式の指定通り、以下のカラムを定義します。

| カラム | 説明 |
| :--- | :--- |
| `id` | キーのID |
| `userid` | ユーザのID（foreign key） |
| `hashed_password` | ハッシュ化したパスワード |

<details><summary>SQL</summary>

```sql
create table user_key (
	id text not null primary key,
	user_id text not null,
	hashed_password text,
	foreign key (user_id) references user(id)
);
```

:::note info
OAuthなどにより外部サービスと連携する場合はパスワード情報が不要なため、`hashed_password` にnullが格納されます。
:::


</details>

## SvelteKitプロジェクトの作成

GCPとデータベースの準備ができました！
次はSvelteKitプロジェクトを作成します。

```bash: SvelteKitプロジェクト作成
npm create svelte@latest auth-google
```

プロジェクト内に移動し、必要なライブラリをしめやかにインストールします。

```bash: ライブラリのインストール
npm i lucia @lucia-auth/oauth @lucia-auth/adapter-sqlite better-sqlite3 @types/better-sqlite3
```

プロジェクトフォルダ直下に `.env.local` ファイルを作成します。
以下のように設定し、控えておいたOAuthクライアントの情報をSvelteKitから参照できるようにします。

```ini: .env.local
GOOGLE_CLIENT_ID=[クライアントID]
GOOGLE_CLIENT_SECRET=[クライアントシークレット]
GOOGLE_REDIRECT_URI=[コールバックURL]
```

## Luciaのセットアップ

いよいよ実装していきます！

まずはLuciaの初期化処理を作ります。

<details><summary>実装内容</summary>

```typescript: src/lib/server/lucia.ts
import { betterSqlite3 } from '@lucia-auth/adapter-sqlite';
import { google } from '@lucia-auth/oauth/providers';
import sqlite from 'better-sqlite3';
import { lucia } from 'lucia';
import { sveltekit } from 'lucia/middleware';

import { dev } from '$app/environment';
import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI } from '$env/static/private';

// DBを指定
const db = sqlite('database.db');

// Luciaを初期化
export const auth = lucia({
  env: dev ? 'DEV' : 'PROD',
  middleware: sveltekit(),
  // SQLite3をDBとして使用
  adapter: betterSqlite3(db, {
    user: 'user',
    key: 'user_key',
    session: 'user_session'
  }),
  // セッションの有効期限をGoogleのアクセストークンの有効期限に合わせる
  sessionExpiresIn: {
    activePeriod: 60 * 30,
    idlePeriod: 60 * 60 * 24
  },
  sessionCookie: {
    expires: false
  },
  // Googleから取得するユーザ情報
  getUserAttributes: (data) => {
    return {
      googleEmail: data.email,
      googleUsername: data.username
    };
  }
});

// Google OAuth用のハンドラを初期化
export const googleAuth = google(auth, {
  clientId: GOOGLE_CLIENT_ID,
  clientSecret: GOOGLE_CLIENT_SECRET,
  redirectUri: GOOGLE_REDIRECT_URI,
  scope: [
    'https://www.googleapis.com/auth/userinfo.profile',
    'https://www.googleapis.com/auth/userinfo.email'
  ]
});

export type Auth = typeof auth;
```
</details>

この時点では、 `getUserAttributes` が返すオブジェクトの `data.email` などでコンパイルエラーが発生します。
このエラーは、Userテーブルに追加したカラムの型がSvelteKit側で定義されていないことにより起きています。

上記をふくめログイン処理で使う型を定義し、エラーが解消されることを確認します。

<details><summary>実装内容</summary>

```typescript: src/app.d.ts
/// <reference types="lucia" />

declare global {
  namespace App {
    interface Locals {
      // AuthRequestインスタンスの格納用
      auth: import('$lib/server/lucia').AuthRequest;
    }
  }

  namespace Lucia {
    type Auth = import('$lib/server/lucia').Auth;
    // Userテーブルに追加したカラムを定義
    type DatabaseUserAttributes = {
      username: string;
      email: string;
    };
    // Sessionテーブルに追加したカラム（今回は追加なし）
    type DatabaseSessionAtibutes = NonNullable<unknown>;
  }
}

export {};
```

</details>

Luciaのドキュメントでは、セッションの検証に使用する `AuthRequest` インスタンスをリクエスト単位で使いまわすことを勧めています。
これにより `AuthRequest` インスタンス内にキャッシュされたリクエストの参照が可能となり、パフォーマンスが向上するようです。

<details><summary>実装内容</summary>

```typescript: src/hooks.server.ts
import { auth } from '$lib/server/lucia';
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
  // AuthRequestインスタンスを格納して、使いまわせるようにする
  event.locals.auth = auth.handleRequest(event);
  return await resolve(event);
};
```

</details>

## ログイン処理を作る

OAuthサービスプロバイダと通信し、ユーザをログイン / サインインさせるAPIを作ります。

まずは、ログインしようとしたユーザをGoogleの同意画面にリダイレクトします。

<details><summary>実装内容</summary>

```typescript: src/routes/login/google
import { dev } from '$app/environment';
import { googleAuth } from '$lib/server/lucia.js';

/**
 * Google OAuthの認可リクエストを送信する
 * @returns 同意画面URLへのリダイレクト
 */
 export const GET = async ({ cookies }) => {
  // 認可リクエスト用URLを取得
  const [url, state] = await googleAuth.getAuthorizationUrl();
  // cookieに状態を格納
  cookies.set('google_oauth_state', state, {
    httpOnly: true,
    secure: !dev,
    path: '/',
    // 30min
    maxAge: 60 * 30
  });
  return new Response(null, {
    status: 302,
    headers: {
      Location: url.toString()
    }
  });
};
```

</details>

同意画面でユーザの同意が得られると、サービスプロバイダはユーザを指定されたコールバックURLにリダイレクトします。
リダイレクト先で、アプリケーションはユーザに対しセッションを発行し、ログイン / サインインさせます。

<details><summary>実装内容</summary>

```typescript: src/routes/login/google/callback
import { auth, googleAuth } from '$lib/server/lucia.js';
import { OAuthRequestError } from '@lucia-auth/oauth';

/**
 * OAuth同意後のコールバック
 * @returns トップページへのリダイレクト
 */
export const GET = async ({ url, cookies, locals }) => {
  // リクエストのstateを検証
  const storedState = cookies.get('google_oauth_state');
  const state = url.searchParams.get('state');
  const code = url.searchParams.get('code');
  // stateが不正ならhttp 400
  if (!storedState || !state || storedState !== state || !code) {
    return new Response(null, { status: 400 });
  }

  try {
    const { getExistingUser, googleUser, createUser } = await googleAuth.validateCallback(code);
    
    const getUser = async () => {
      // サインイン済のユーザであればそのユーザ情報を返す（ログイン）
      const existingUser = await getExistingUser();
      if (existingUser) {
        return existingUser;
      }

      // ユーザ新規作成（サインイン）
      if (!googleUser.email_verified) {
        // Google側でメールアドレスを認証しているため不要かも？
        throw new Error('メールアドレスが認証されていません');
      }
      const user = await createUser({
        // useridはluciaにより自動生成される
        // その他のユーザ属性
        attributes: {
          username: googleUser.name,
          email: googleUser.email ?? ''
        }
      });
      return user;
    };

    // ユーザ作成、もしくは既存ユーザを取得
    const user = await getUser();
    // ユーザ取得失敗
    if (!user) {
      return new Response(null, { status: 500 });
    }

    // セッション作成
    const session = await auth.createSession({
      userId: user.userId,
      attributes: {}
    });
    locals.auth.setSession(session);

    // ログイン完了後は、トップページにリダイレクト
    return new Response(null, {
      status: 302,
      headers: { Location: '/' }
    });
  } catch (e) {
    if (e instanceof OAuthRequestError) {
      // invalid code
      return new Response(null, {
        status: 400
      });
    }

    return new Response(null, { status: 500 });
  }
};
```

</details>

## ログイン画面を作る

ログイン画面を作ります。

この画面で `Googleアカウント` ボタンを押すと、GoogleでOAuthログイン / サインインができるという寸法です。

<details><summary>実装内容</summary>

```html: src/routes/login/+page.svelte
<h1>ログイン / ユーザ登録</h1>
<a href="/login/google" role="button">Googleアカウント</a>
```

</details>

## ログイン状態に応じたページの権限制御を作る

ログインしているか確認できるように、ログイン中のユーザの情報を表示する画面をトップページとして作ります。
この画面にはログアウトボタンも用意します。

加えて、この画面ではユーザのログイン状態に応じ、以下の制御を行います。

- 未ログイン、またはセッション切れ → ログイン画面にリダイレクト
- ログイン済 → 画面にユーザ情報を表示

<details><summary>実装内容</summary>

ユーザ情報は、画面のロード時にセッションから取得します。
ログアウト処理はSvelteのform actionsとして作ります。

```typescript: src/routes/+page.server.svelte
import { redirect } from '@sveltejs/kit';

import type { Actions, PageServerLoad } from './$types';
import { auth } from '$lib/server/lucia';

/**
 * ログインユーザの情報を取得する
 * @returns ユーザ情報
 */
export const load: PageServerLoad = async ({ locals }) => {
  const session = await locals.auth.validate();
  // セッションがない、もしくは不正ならログインページにリダイレクト
  if (!session) {
    throw redirect(302, '/login');
  }
  return {
    userId: session.user.userId,
    mail: session.user.googleEmail,
    googleUsername: session.user.googleUsername
  };
};

export const actions = {
  // ログアウトAction
  logout: async ({ locals }) => {
    const session = await locals.auth.validate();
    // セッションが不正ならログインページにリダイレクト
    if (!session) {
      throw redirect(302, '/login');
    }

    // セッションを無効化してログインページにリダイレクト
    await auth.invalidateSession(session.sessionId);
    await auth.deleteDeadUserSessions(session.userId);
    locals.auth.setSession(null);
    throw redirect(302, '/login');
  }
} satisfies Actions;
```

画面を作ります。

```html: src/routes/+page.svelte
<script lang="ts">
  import type { PageData } from './$types';

  import { enhance } from '$app/forms';

  export let data: PageData;
</script>

<h1>ログイン情報</h1>

<table>
  <thead>
    <tr>
      <th>ユーザID</th>
      <th>メールアドレス</th>
      <th>Googleのユーザ名</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>{data.userId}</td>
      <td>{data.mail}</td>
      <td>{data.googleUsername}</td>
    </tr>
  </tbody>
</table>

<form method="post" action="?/logout" use:enhance>
  <input type="submit" value="ログアウト" />
</form>

```

</details>

私の場合は [Pico CSS](https://picocss.com/) も導入して、画面の見た目を簡単に整えました。
Pico CSSは軽くてカスタマイズもしやすい、素敵なCSSフレームワークです。

# ログインしてみる

では動作確認してみましょう！

アプリケーションを起動し、トップページ（ http://localhost:5173 ）にアクセスします。
今はまだログインしていないため、ログイン画面（ http://localhost:5173/login ）にリダイレクトされます。 

![トップページ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620754/08c8199e-03c3-48bb-d45d-10f864c25ba6.png)

`Googleアカウント` ボタンを押すと、認可リクエストと共にGoogleのOAuth同意画面にリダイレクトされます。

![GoogleのOAuth認証画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620754/eaf79690-a413-260c-b44d-0e779599431a.png)

メールアドレスとパスワードを入力してGoogleにログインすると、認可レスポンスと共にコールバックURL（ http://localhost:5173/login/google/callback ）にリダイレクトされます。

その結果、 [ログイン画面を作る](#ログイン画面を作る)で実装したとおりにログイン処理が実行された後、トップページにリダイレクトされます。

![ログイン後のトップページ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620754/fd361b30-5064-93b7-c97e-3e709cda0e6f.png)

無事にユーザ情報が表示されていますね！
ログイン成功です！

ユーザ情報の下にあるログアウトボタンを押すと、ユーザがログアウトされたうえでトップページにリダイレクトされます。

# おわりに

公式ガイドをベースに作ってみただけですが、認可・認証にはまだまだ学習すべきことが多いなと改めて感じました。
投稿内容に改善点や間違いなどあればぜひご指摘ください。

# 今後やりたいこと

- OpenID Connectで認証
- 普通のパスワード認証＋MFA
- メールアドレス検証など、セキュリティの向上
- PostgreSQL＋適当なORMライブラリに乗り換え