SvelteKitでパスキーを使えるようにする

# 概要
この記事ではSvelteKitのアプリケーションでパスキー認証をするための最低限の手順や必要事項をまとめます。
この記事内では以下の構成を前提に、順を追ってセットアップを解説をします。
- フレームワーク
    - Svelte 5
    - SvelteKit
- インフラ
    - サーバー: Cloudflare Workers
    - DB: Cloudflare D1
    - KV Store: Cloudflare Workers KV
- ORM
    - Drizzle
- セッション管理
    -  svelte-kit-sessions
- パスキーライブラリ
    - SimpleWebAuthn

:::note info
**この記事の内容での登壇発表アーカイブがあります**
この記事の内容の一部は[**Svelte Japan Offline Meetup #3**](https://svelte-jp.connpass.com/event/338870/)にて発表しました。
音声で説明された方が分かりやすい部分もあると思うので、適宜アーカイブなども参照してみてください！

[![Svelte Japan Offline Meetup #3](http://img.youtube.com/vi/Duj8GIf2cQ0/0.jpg)](https://www.youtube.com/watch?v=Duj8GIf2cQ0)
:::


# SvelteKitのセットアップ
まずはSvelteKitのセットアップを行います。
SvelteのCLIとして`sv`コマンドが用意されているので、これを使ってセットアップをします。
以下のコマンドを実行して最低限の環境としてプロジェクトを初期化します。
パッケージマネージャーはお好みのものをどうぞ（僕はbunが大好きです）
```bash
bunx sv create --template minimal --types ts
```

```shell-session
$ bunx sv create --template minimal --types ts
┌  Welcome to the Svelte CLI! (v0.6.6)
│
◇  Where would you like your project to be created?
│  ./
│
◆  Project created
│
◇  What would you like to add to your project? (use arrow keys / space bar)
│  none
│
◇  Which package manager do you want to install dependencies with?
│  bun
│
◆  Successfully installed dependencies
│
◇  Project next steps ─────────────────────────────────────────────────────╮
│                                                                          │
│  1: git init && git add -A && git commit -m "Initial commit" (optional)  │
│  2: bun dev --open                                                       │
│                                                                          │
│  To close the dev server, hit Ctrl-C                                     │
│                                                                          │
│  Stuck? Visit us at https://svelte.dev/chat                              │
│                                                                          │
├──────────────────────────────────────────────────────────────────────────╯
│
└  You're all set!
```
svコマンドのオプションや引数などの詳細はドキュメントを確認してみてください。

https://svelte.dev/docs/cli/overview

この時点でのファイル構成は以下のようになっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180518/923fb0cd-bc0d-96db-071e-693b4f808164.png)

# Cloudflare Workersへデプロイできるようにする
Cloudflare Workersにアプリケーションをデプロイしたり、Cloudflare D1やCloudflare Workers KVを構成するには、CLIツールのWranglerと構成ファイルの`wrangler.toml`を使います。

まずは以下の内容でプロジェクト直下に`wrangler.toml`ファイルを作ります。
`name`とかはお好みで変えて大丈夫です。
```wrangler.toml
name = "sveltekit-passkeys"
compatibility_date = "2024-12-09"

main = "./.cloudflare/worker.js"

site.bucket = "./.cloudflare/public"

build.command = "bun run build"

compatibility_flags = [ "nodejs_compat" ]
```

SvelteKitのアダプターもCloudflare Workers用のものがあるので、それを使うようにします。
まずはパッケージをインストールして、
```bash
bun i -D @sveltejs/adapter-cloudflare-workers
```
`svelte.config.js`を書き換えます。
```diff_javascript:svelte.config.js
- import adapter from '@sveltejs/adapter-auto';
+ import adapter from '@sveltejs/adapter-cloudflare-workers';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

/** @type {import('@sveltejs/kit').Config} */
const config = {
	// Consult https://svelte.dev/docs/kit/integrations
	// for more information about preprocessors
	preprocess: vitePreprocess(),

	kit: {
		// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
		// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
		// See https://svelte.dev/docs/kit/adapters for more information about adapters.
		adapter: adapter()
	}
};

export default config;
```

## Cloudflare D1でDBを作る
このタイミングで一緒にDBも準備しておきます。
以下のコマンドを実行するとセットアップに必要な情報を取得することができます。

```bash
bunx wrangler d1 create sveltekit-passkeys
```

```shell_session
$ bunx wrangler d1 create sveltekit-passkeys
--------------------
🚧 D1 is currently in open alpha and is not recommended for production data and traffic
🚧 Please report any bugs to https://github.com/cloudflare/workers-sdk/issues/new/choose
🚧 To request features, visit https://community.cloudflare.com/c/developers/d1
🚧 To give feedback, visit https://discord.gg/cloudflaredev
--------------------

✅ Successfully created DB 'sveltekit-passkeys'!

Add the following to your wrangler.toml to connect to it from a Worker:

[[ d1_databases ]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "sveltekit-passkeys"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
## ↑↑ 実際の出力ではIDっぽいものが表示されています
```
出力にある↓↓
```toml
[[d1_databases]]
binding = "DB"
database_name = "sveltekit-passkeys"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
```
の部分をコピーして自分の`wrangler.toml`に貼り付けます。

これでCloudflare Workersの中からD1を使えるようになります。

# Drizzleのセットアップ
次にORMとしてDrizzleをセットアップします。
SvelteKitやCloudflare D1と組み合わせて使えるように設定していきます。

まずは必要なパッケージをインストールします。
```bash
bun i -D drizzle-orm drizzle-kit @paralleldrive/cuid2 @cloudflare/workers-types dotenv
```
次に、以下の内容でプロジェクト直下に`drizzle.config.ts`ファイルを作ります。
```drizzle.config.ts
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  out: './drizzle',
  schema: './src/lib/db/schema.ts',
  dialect: 'sqlite',
  driver: 'd1-http',
  dbCredentials: {
    accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
    databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
    token: process.env.CLOUDFLARE_D1_TOKEN!,
  },
});

```
ファイル内に記述のある`./src/lib/db/schema.ts`は、後ほど作成します。

この辺りの手順の詳細はDrizzleのドキュメントに記載があります。

https://orm.drizzle.team/docs/get-started/d1-new

## Cloudflareの各種情報を揃える

`dbCredentials`のところの各種値はDrizzle Kitを使ってマイグレーションをするのに使います。
必要な値はそれぞれ以下の手順で取得します。

### accountId
1. Cloudflareのダッシュボードにアクセスします
https://dash.cloudflare.com

1. 左側のメニューから「Workers & Pages」を選びます
![SS 2024-12-08 14.20.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180518/42a80af2-3ef9-e3cc-dd18-a866a8176528.png)

1. 「アカウントの詳細」の欄にアカウントIDがあります
![SS 2024-12-08 14.24.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180518/bd5cbe12-7f47-cd08-16d7-8fc1316faf03.png)

### databaseId
[Cloudflare D1でDBを作る](#cloudflare-d1でdbを作る)の手順で`wrangler.toml`に貼り付けた`database_id`と同じです。

### token
1. ユーザーAPIトークンの管理ページにアクセスします
https://dash.cloudflare.com/profile/api-tokens
1. トークンを新しく作成します
![SS 2024-12-08 14.42.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180518/a4c06b8b-ab7e-14ae-b296-17c7e1686f94.png)
1. カスタムトークンを選びます
![SS 2024-12-08 14.42.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180518/173595e5-eb21-1627-1785-a5455a4854ae.png)
1. D1を編集する権限をつけます
![SS 2024-12-08 14.43.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180518/59134e50-b90a-3615-536f-bb9d406075f7.png)
1. 作成を進めると、最後にトークンが表示されます
![SS 2024-12-08 14.43.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180518/162a4efc-bdec-b3a1-41b5-5c6dc5af43ad.png)
:::note warn
このAPIトークンは再度表示できないので、必ず控えておきます
:::

得られた3つの認証情報は、`.env`ファイルに記載しておきます。
```sh:.env
CLOUDFLARE_ACCOUNT_ID=123456789xxxxxxx123456789xxxxxxx
CLOUDFLARE_DATABASE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
CLOUDFLARE_D1_TOKEN=1234XXXX1234XXXX1234XXXX1234XXXX1234XXXX
```

## SvelteKitからDrizzleを使いやすいようにする

最後に、SvelteKitの中でDrizzleを利用しやすくします。
以下のファイルを追加します。
```src/lib/drizzle.ts
import { env } from '$env/dynamic/private';
import { drizzle } from 'drizzle-orm/d1';
import * as schema from './db/schema';

export const db = drizzle(env.DB as unknown as D1Database, { schema });
```

このままだと`D1Database`の型が使えなくてエラーになってしまうので、以下のように`tsconfig.json`を編集します。
```diff_jsonnet:tsconfig.json
{
	"extends": "./.svelte-kit/tsconfig.json",
	"compilerOptions": {
    "allowJs": true,
    "checkJs": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
+   "types": ["@cloudflare/workers-types/2023-07-01"],
    "skipLibCheck": true,
    "sourceMap": true,
    "strict": true,
    "moduleResolution": "bundler"
  }
	// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
	// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
	//
	// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
	// from the referenced tsconfig.json - TypeScript does not merge them in
}
```

これでDrizzleの準備は完了です。

# DBのマイグレーション
次はユーザーやパスキーを管理するためのDBテーブルを作成します。
Cloudflare D1では`wrangler`でコマンドを実行することでSQLを使ってDBを操作することができますが、今回はDrizzleを利用しているのでDrizzleのスキーマを用意するだけでマイグレーションまで一気に実施することができます。

まずは以下のスキーマを追加します。
```src/lib/db/schema.ts
import { createId } from '@paralleldrive/cuid2';
import { relations, sql } from 'drizzle-orm';
import { blob, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';

export const users = sqliteTable('User', {
  id:   text().primaryKey().$default(() => createId()),
  name: text(),
});

export const passkeys = sqliteTable('Passkey', {
  id:               text().primaryKey().$default(() => createId()),
  public_key:       blob().notNull(),
  user_id:          text().notNull(),
  webauthn_user_id: text().notNull().unique(),
  counter:          integer().notNull(),
  device_type:      text().notNull(),
  backed_up:        integer({ mode: 'boolean' }).notNull(),
  transports:       text(),
  created_at:       integer({ mode: 'timestamp' }).default(sql`(CURRENT_TIMESTAMP)`),
});

export const usersRelations = relations(users, ({ many }) => ({
  passkeys: many(passkeys),
}));

export const passkeyssRelations = relations(passkeys, ({ one }) => ({
  owner: one(users, {
    fields: [passkeys.user_id],
    references: [users.id],
  }),
}));
```
※見やすいようにスペースを入れてます

テーブルの構成や、それぞれのカラムが何に使われるかなどは、SimpleWebAuthnのページに詳しく記載があります。

https://simplewebauthn.dev/docs/packages/server

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180518/11b2067c-39b8-297e-708d-8a768706f4cf.png)
出典: [@simplewebauthn/server | SimpleWebAuthn](https://simplewebauthn.dev/docs/packages/server)

あとはスキーマの内容をDBに反映すれば完了です。
以下のコマンドで`drizzle/0000_hoge_fuga_piyo.sql`にマイグレーションファイルが作成されます。
```bash
bunx drizzle-kit generate
```

次に以下のコマンドを実行すれば実際のCloudflare D1にテーブルが作成されます。
```bash
bunx drizzle-kit migrate
```
結果はCloudflareのダッシュボードから確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180518/3e3209e4-c843-23bb-09cf-3bfae981b4df.png)

:::note warn
マイグレーションを反映するコマンドについて
```bash
bunx drizzle-kit push
```
だとうまくいきませんでした。
:::

これだけだとCloudflare側でテーブルができただけなので、以下のコマンドでローカルのDBにもテーブルを作っておきます。
```bash
bunx wrangler d1 execute sveltekit-passkeys --file=drizzle/0000_hoge_fuga_piyo.sql
```
以下のコマンドでその結果を確認できます。
```bash
 bunx wrangler d1 execute sveltekit-passkeys --command="select name from sqlite_master where type = 'table'"
```

```shell_session
$ bunx wrangler d1 execute sveltekit-passkeys --command="select name from sqlite_master whe
re type = 'table'"

 ⛅️ wrangler 3.93.0
-------------------

🌀 Executing on local database sveltekit-passkeys (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) from .wrangler/state/v3/d1:
🌀 To execute on your remote database, add a --remote flag to your wrangler command.
🚣 1 command executed successfully.
┌─────────┐
│ name    │
├─────────┤
│ Passkey │
├─────────┤
│ User    │
└─────────┘
```

# セッション管理の準備
認証後にログイン状態を維持できるように、セッション管理の仕組みを導入します。
今回は`svelte-kit-sessions`を使います。

まずは必要なパッケージをインストールします。
```bash
bun i -D svelte-kit-sessions svelte-kit-connect-cloudflare-kv
```
セッションに関する処理は毎アクセスごとに必要なので、SvelteKitのHooksを使います。
以下の内容で`hooks.server.ts`を作成します。
```hoks.server.ts
import type { Handle } from '@sveltejs/kit';
import type { SessionData as OriginalSessionData } from 'svelte-kit-sessions';
import { env } from '$env/dynamic/private';
import KvStore from 'svelte-kit-connect-cloudflare-kv';
import { sveltekitSessionHandle } from 'svelte-kit-sessions';

declare module 'svelte-kit-sessions' {
  interface SessionData {
    userId?: string;
    challenge?: string;
  }
}

// 型情報が正しく動くようにするためのWorkaround
declare module 'svelte-kit-connect-cloudflare-kv' {
  interface SessionData extends OriginalSessionData {}
}

export const handle: Handle = async ({ event, resolve }) => {
  let sessionHandle: Handle | null = null;

  if (event.platform && event.platform.env) {
    // https://kit.svelte.dev/docs/adapter-cloudflare#bindings
    const store = new KvStore({ client: event.platform.env.session });
    sessionHandle = sveltekitSessionHandle({
      secret: env.SESSION_SECRET,
      store,
      cookie: {
        maxAge: 3600 * 8,
        httpOnly: true,
        sameSite: 'strict',
        secure: true,
        path: '/',
      },
      rolling: true,
    });
  }

  return sessionHandle ? sessionHandle({ event, resolve }) : resolve(event);
};
```
これでアクセスしたユーザーごとにセッションが作られるようになります。
また、パスキーでの認証の過程で必要なチャレンジも一緒に保存するようにしています。

`sveltekitSessionHandle()`の初期化の中で`secret: env.SESSION_SECRET`を指定していますが、これはセッション情報の暗号化に使うシークレットです。
充分長い英数の文字列として、`.env`ファイルの中に追記しておいてください。
```diff_shell:.env
CLOUDFLARE_ACCOUNT_ID=123456789xxxxxxx123456789xxxxxxx
CLOUDFLARE_DATABASE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
CLOUDFLARE_D1_TOKEN=1234XXXX1234XXXX1234XXXX1234XXXX1234XXXX

+ SESSION_SECRET=enough-long-random-string-hoge-fuga
```

また、型情報を正しく扱えるようにするために`app.d.ts`も編集します。

```diff_typescript:app.d.ts
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
  namespace App {
    // interface Error {}
    // interface Locals {}
    // interface PageData {}
    // interface PageState {}
+   interface Platform {
+     env: Env;
+     cf: CfProperties;
+     ctx: ExecutionContext;
+   }
  }
}

export {};
```

この辺りの詳細はそれぞれのライブラリのREADME.mdに記載があります。

https://kit.svelte.jp/docs/hooks

https://github.com/yutak23/svelte-kit-sessions

https://github.com/yutak23/svelte-kit-connect-cloudflare-kv

また、今回の使用にあたってこちらの記事も参考にさせていただきました。ありがとうございます。

https://qiita.com/yuta-katayama-23/items/70bbfbd7829df9712495

最後に、セッションの保存先としてCloudflare Workers KVをセットアップします。
以下のコマンドを実行するとKVが作成されて必要な設定情報が出力されます。
```bash
bunx wrangler kv namespace create session
```
出力にある↓↓
```toml
[[kv_namespaces]]
binding = "session"
id = "12345xxxxx12345xxxxxx12345xxxxxx"
```
の部分をコピーして自分の`wrangler.toml`に貼り付けます。

これでCloudflare Workersの中からKVを使えるようになります。

# パスキーの準備
ここからいよいよパスキーを使えるようにしていきます。
まずは必要なパッケージをインストールします。
```bash
bun i -D @simplewebauthn/browser @simplewebauthn/server
```

次に、パスキーに必要な定数を`.env`ファイルの中で定義します。
```diff_shell:.env
CLOUDFLARE_ACCOUNT_ID=123456789xxxxxxx123456789xxxxxxx
CLOUDFLARE_DATABASE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
CLOUDFLARE_D1_TOKEN=1234XXXX1234XXXX1234XXXX1234XXXX1234XXXX

SESSION_SECRET=enough-long-random-string-hoge-fuga

+ PUBLIC_RP_NAME=Your App Name
+ PUBLIC_RP_ID=example.com
+ PUBLIC_ORIGIN=https://example.com
```
ここで登場した「RP」とはRelying Partyの略で、サービス提供者、もっと端的に言えばサイトのことです。
RPの名前とドメイン、オリジンをそれぞれ指定します。

なお、ローカル開発環境においては以下のようにします。

```diff_shell:.env
CLOUDFLARE_ACCOUNT_ID=123456789xxxxxxx123456789xxxxxxx
CLOUDFLARE_DATABASE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
CLOUDFLARE_D1_TOKEN=1234XXXX1234XXXX1234XXXX1234XXXX1234XXXX

SESSION_SECRET=enough-long-random-string-hoge-fuga

+ PUBLIC_RP_NAME=Your App Name
+ PUBLIC_RP_ID=localhost
+ PUBLIC_ORIGIN=http://localhost:8787
```

また、`SESSION_SECRET`は別途`.dev.vars`を作成してにローカル用のものを記載しておきます。
```.dev.env
SESSION_SECRET=enough-long-random-string-hoge-fuga
```

# ユーザー登録の実装
パスキーでログインができるようにするには、まずパスキーでユーザー登録ができる必要があります。
ID / パスワード形式のユーザー管理に後付けする形でパスキーを実装することもできますが、今回はパスキーを唯一のユーザー認証方法とする前提で実装を進めます。

まずは、ユーザー登録用のページを新しく作ります。
```svelte:src/routes/register/+page.svelte
<script lang='ts'>
  let username = $state('');

  async function createPasskey() {
  }
</script>

<form action="">
  <label>ユーザー名
    <input type='text' required bind:value={username}>
  </label>
  <button onclick={createPasskey} disabled={username === ''}>登録</button>
</form>
```
UI自体はシンプルなユーザー登録画面です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180518/147ba0fc-f065-55ed-5268-07fe49d5e6c3.png)

今の状態だと登録ボタンを押しても何も起きないので、`createPasskey()`関数の中身と必要なAPIを実装していきます。

## パスキーでのユーザー登録の手順
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180518/a57f649b-0882-0b17-d703-252384e92366.png)
出典: [パスキーはパスワード代わり？どうすれば使えるのか | サイバーセキュリティ情報局](https://eset-info.canon-its.jp/malware_info/special/detail/240213.html)

ここで一旦パスキーによるユーザー登録・認証のフローを確認します。
パスキーはパスワードなしでユーザーを認証する技術です。その背後では公開鍵暗号が重要な役割を果たしています。

今回実装しているUIにおいては、ユーザーがユーザー名を入力して登録ボタンを押すと、ブラウザからサーバーにその情報が送られます。また、それと同時にチャレンジの情報も要求します。

サーバー側では受け取った情報をもとに新規ユーザーを作成し、一緒にチャレンジの情報も生成します。
これらの情報をセッションに保存した上で、チャレンジをレスポンスとして返します。

チャレンジとはパスキーの認証に使われるランダムな文字列で、チャレンジを受け取ったブラウザはこれをデバイス上の秘密鍵で署名し、有効な鍵を持っていることを証明します。
サーバー側は署名されたチャレンジを、それと一緒に送られてくる公開鍵を使って検証することで、正しく認証が行われていることを確かめることができます。

:::note warn
正確な情報は改めて確認してください
嘘を書いているつもりはありませんが、詳細は省いてなんとなくの理解で説明している部分が多々あるので、正確な情報は各自様々な情報を調べてみてください。
:::

## API
パスキーでのユーザー登録のフローがわかったところで、まずは必要なAPIを作成します。

必要なのは

- ユーザー登録とチャレンジ生成を行うエンドポイント
- チャレンジを検証し、パスキーを登録するエンドポイント

の2つです。

まずは1つ目の**ユーザー登録とチャレンジ生成を行うエンドポイント**を作成します。

```src/routes/api/auth/register/+server.ts
import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types';
import type { RequestHandler } from './$types';
import { users } from '$lib/db/schema';
import { db } from '$lib/drizzle';
import { generateRegistrationOptions } from '@simplewebauthn/server';
import { error, json } from '@sveltejs/kit';
import { PUBLIC_RP_ID, PUBLIC_RP_NAME } from '$env/static/public';

export const POST: RequestHandler = async ({ request, locals: { session } }) => {
  const body: { username?: string } = await request.json();
  const userName = body.username;
  if (!userName)
    return error(400, 'Parameter missing');
  const [user] = await db.insert(users).values({ name: userName }).returning();

  const options: PublicKeyCredentialCreationOptionsJSON = await generateRegistrationOptions({
    rpName: PUBLIC_RP_NAME,
    rpID: PUBLIC_RP_ID,
    userName,
    userID: new TextEncoder().encode(user.id),
    attestationType: 'none',
    // Prevent users from re-registering existing authenticators
    excludeCredentials: undefined, // TODO: Get existing credentials,
    // See "Guiding use of authenticators via authenticatorSelection" below
    authenticatorSelection: {
      residentKey: 'required',
      userVerification: 'preferred',
      authenticatorAttachment: 'platform',
    },
  });

  await session.regenerate();
  session.cookie.path = '/';
  await session.setData({
    userId: user.id,
    challenge: options.challenge,
  });
  await session.save();

  return json({ options });
};
```
4つのブロックで、それぞれ順に

1. 新規ユーザー作成
1. チャレンジの生成
1. セッションへの保存
1. レスポンス返却

を行っています。

次に、**チャレンジを検証し、パスキーを登録するエンドポイント**を作成します。

```src/routes/api/auth/register/verify-challenge/+server.ts
import type { RegistrationResponseJSON } from '@simplewebauthn/types';
import type { RequestHandler } from './$types';
import { Buffer } from 'node:buffer';
import { passkeys } from '$lib/db/schema';
import { db } from '$lib/drizzle';
import { verifyRegistrationResponse } from '@simplewebauthn/server';
import { json } from '@sveltejs/kit';
import { PUBLIC_ORIGIN, PUBLIC_RP_ID } from '$env/static/public';

export const POST: RequestHandler = async ({ request, locals: { session } }) => {
  const registrationResponseJSON: RegistrationResponseJSON = await request.json();

  const expectedChallenge = session.data.challenge;
  if (!expectedChallenge)
    return json({ error: 'Parameters incorrect' }, { status: 400, statusText: 'Bad Request' });

  const verification = await (async () => {
    try {
      return await verifyRegistrationResponse({
        response: registrationResponseJSON,
        expectedChallenge,
        expectedOrigin: PUBLIC_ORIGIN,
        expectedRPID: PUBLIC_RP_ID,
      });
    }
    catch (error) {
      console.error(error);
    }
  })();

  if (!verification)
    return json({ error: 'Challenge verification failed' }, { status: 400, statusText: 'Bad Request' });

  const { verified } = verification;
  const { registrationInfo } = verification;

  const user = await db.query.users.findFirst({
    where: ({ id }, { eq }) => eq(id, session.data.userId ?? ''),
  });

  if (verified && registrationInfo && user) {
    const {
      credential,
      credentialDeviceType,
      credentialBackedUp,
    } = registrationInfo;

    await db.insert(passkeys)
      .values({
        user_id: user.id,
        webauthn_user_id: user.id,
        id: credential.id,
        public_key: Buffer.from(credential.publicKey),
        counter: credential.counter,
        transports: credential.transports?.join(',') ?? null,
        device_type: credentialDeviceType,
        backed_up: credentialBackedUp,
      });

    session.cookie.path = '/';
    await session.setData({ userId: user.id });
    await session.save();
  }

  return json({ verified });
};
```
`verifyRegistrationResponse()`の部分で、ブラウザから送り返されてきた署名済みチャレンジを検証しています。

この辺りの詳細はSimpleWebAuthnのドキュメントに記載があります。

https://simplewebauthn.dev/docs/packages/server

チャレンジの検証が終わったら、パスキーの情報をDBに保存しています。
また、セッションの情報も改めて保存しています。（冗長な気もしますが念のため）

## ブラウザ側のでの処理
続いてブラウザ側では、先ほど作った2つのエンドポイントに対してリクエストを行う各種処理を実装していきます。

先ほどの`src/routes/register/+page.svelte`の中で未実装だった`createPasskey()`の処理を以下のように実装します。

```svelte:src/routes/register/+page.svelte
<script lang='ts'>
  import type { VerifiedRegistrationResponse } from '@simplewebauthn/server';
  import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types';
  import { goto, invalidateAll } from '$app/navigation';
  import { startRegistration } from '@simplewebauthn/browser';

  let username = $state('');
  async function createPasskey() {
    if (username === '') {
      return;
    }
    const { options } = await (await fetch('/api/auth/register', {
      method: 'POST',
      body: JSON.stringify({ username }),
      credentials: 'same-origin',
      headers: {
        'Content-Type': 'application/json',
      },
    })).json().catch((err) => {
      console.error(err);
    }) as { options: PublicKeyCredentialCreationOptionsJSON | null };
    if (!options)
      return;
    const registrationResponse = await startRegistration({ optionsJSON: options });

    const verificationJSON: VerifiedRegistrationResponse = await (await fetch('/api/auth/register/verify-challenge', {
      method: 'POST',
      credentials: 'same-origin',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(registrationResponse),
    })).json();

    if (verificationJSON.verified) {
      await invalidateAll();
      goto('/');
    }
  }
</script>

<form action="">
  <label>ユーザー名
    <input type='text' required bind:value={username}>
  </label>
  <button onclick={createPasskey} disabled={username === ''}>登録</button>
</form>
```
上から順に、

1. `const { options } = ...`: ユーザー名の受け渡しとチャレンジの取得
1. `startRegistration()`: 認証器に対して認証（署名やパスキーの保存など）の要求
1. `const verificationJSON: ...`: 認証結果の送信
1. ページデータ(表示)のリフレッシュとトップページへの遷移

ユーザーからすると、2番のステップのタイミングでTouch IDやFace IDなどによるパスキーの認証を求められることになります。

# ログインユーザーの表示
ログインができたら現在のユーザーを確認できた方が便利です。
今回は簡易的にユーザー名を表示してみたいと思います。
トップページに相当する`src/routes/+page.svelte`を以下のようにします。

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

  const layout: { data: LayoutServerData } = $props();
</script>

{#if layout.data.user}
  {layout.data.user?.name}さん、こんにちは！
{/if}
```

`+layout.server.ts`でセッション情報からユーザーを取得して、ページ側に渡す前提になっています。
これに必要な`+layout.server.ts`は次のようになります。

```src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
import { db } from '$lib/drizzle';

export const load: LayoutServerLoad = async ({ locals: { session } }) => {
  const { userId } = session.data;
  if (!userId) {
    return {
      user: undefined,
    };
  }

  const user = await db.query.users.findFirst({
    where: ({ id }, { eq }) => eq(id, userId),
  });

  return { user };
};
```
セッションに入っているユーザーIDをもとに実際のユーザーデータをDBから取得しています。

この時点での動作をローカル開発環境で確かめてみます。
まず次のコマンドを実行します。
```bash
bunx wrangler dev
```
SvelteKitのビルドやD1、KVの準備が行われます。
以下の表示が出たら
```shell_session
⎔ Starting local server...
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│  [b] open a browser, [d] open devtools, [l] turn off local mode, [c] clear console, [x] to exit  │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
```
`b`を押してブラウザを開きます。（たぶん http://localhost:8787/ が開くはず）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180518/eaf903b6-ab75-6f11-c92f-60b95b06c4dc.png)

ここにはまだ何もないので、 http://localhost:8787/register にアクセスします。
すると例の登録画面が現れるので、適当なユーザー名を入力します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180518/dccac003-00df-dc79-08f9-34ee7d66ab83.png)
これで登録ボタンを押すとユーザー認証を要求されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180518/37e183db-eac8-7766-d2f6-5d8294a6cd05.png)
認証が完了すると自動的にトップページに遷移し、先ほど入力したユーザー名に対して挨拶が表示されているはずです。:tada:
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180518/d3be35ee-7fb1-ef89-c150-95fc4dff7d06.png)

このページでリロードなどをしてもログイン状態は維持され続けていることが確認できると思います。

# ログイン
無事にユーザー登録ができたので、次はログインをできるようにします。
ユーザー登録と同じく、必要なのはそのためのUIとエンドポイント2つです。
## API
ログインのAPIとして必要なエンドポイントは

- ログイン用のチャレンジ生成を行うエンドポイント
- チャレンジを検証し、ログイン処理を完了するエンドポイント

の2つです。

まずは1つ目の**ログイン用のチャレンジ生成を行うエンドポイント**を作成します。

```src/routes/api/auth/login/+server.ts
import { PUBLIC_RP_ID } from '$env/static/public';
import type { RequestHandler } from './$types';
import { generateAuthenticationOptions } from '@simplewebauthn/server';
import { json } from '@sveltejs/kit';

export const GET: RequestHandler = async ({ locals: { session } }) => {
  const options = await generateAuthenticationOptions({
    rpID: PUBLIC_RP_ID,
    userVerification: 'preferred',
    allowCredentials: [],
  });

  await session.regenerate();
  session.cookie.path = '/';
  await session.setData({
    challenge: options.challenge,
  });
  await session.save();

  return json({ options });
};
```
3つのブロックで、それぞれ順に

1. チャレンジの生成
1. セッションへの保存
1. レスポンス返却

を行っています。

次に、**チャレンジを検証し、ログイン処理を完了するエンドポイント**を作成します。

```src/routes/api/auth/login/verify-challenge/+server.ts
import type { AuthenticationResponseJSON, AuthenticatorTransportFuture } from '@simplewebauthn/types';
import type { RequestHandler } from './$types';
import { passkeys } from '$lib/db/schema';
import { db } from '$lib/drizzle';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { json } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { PUBLIC_ORIGIN, PUBLIC_RP_ID } from '$env/static/public';

export const POST: RequestHandler = async ({ request, locals: { session } }) => {
  const response: AuthenticationResponseJSON = await request.json();
  const expectedChallenge = session.data.challenge;

  const passkey = await db.query.passkeys.findFirst({
    where: ({ id }, { eq }) => eq(id, response.id),
  });

  if (!expectedChallenge || !passkey)
    return json({ error: 'challenge or user not found' }, { status: 400, statusText: 'Bad Request' });

  const verification = await (async () => {
    try {
      return await verifyAuthenticationResponse({
        response,
        expectedChallenge,
        expectedOrigin: PUBLIC_ORIGIN,
        expectedRPID: PUBLIC_RP_ID,
        credential: {
          id: passkey.id,
          publicKey: passkey.public_key as Uint8Array,
          counter: passkey.counter,
          transports: passkey.transports?.split(',') as AuthenticatorTransportFuture[],
        },
      });
    }
    catch (error) {
      console.error(error);
    }
  })();

  if (!verification)
    return json({ error: 'challenge or user not found' }, { status: 400, statusText: 'Bad Request' });

  const { verified } = verification;
  const { newCounter } = verification.authenticationInfo;

  if (verified) {
    await db.update(passkeys)
      .set({ counter: newCounter })
      .where(eq(passkeys.id, passkey.id));
    session.cookie.path = '/';
    await session.setData({ userId: passkey.user_id });
    await session.save();
  }

  return json({ verified });
};
```

上から順に、

1. チャレンジとパスキーの取得
1. チャレンジの検証
1. パスキーの利用に関する情報の更新（カウンターというのがインクリメントします）
1. セッションの更新

を行っています。

## ブラウザ側のでの処理
続いてブラウザ側では、ユーザー登録を実装した時と同じようにこれら2つのエンドポイントに対してリクエストを行う各種処理をログインボタンとして実装していきます。

`AuthButton.svelte`として新しいコンポーネントを以下のように実装します。
```svelte:src/lib/components/AuthButton.svelte
<script lang='ts'>
  import type { VerifiedRegistrationResponse } from '@simplewebauthn/server';
  import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types';
  import { invalidateAll } from '$app/navigation';
  import { startAuthentication } from '@simplewebauthn/browser';

  async function login() {
    const { options } = await (await fetch(`/api/auth/login`)).json().catch((err) => {
      console.error(err);
    }) as { options: PublicKeyCredentialRequestOptionsJSON | null };
    if (!options)
      return;
    const authenticationResponse = await startAuthentication({
      optionsJSON: options,
    });

    const verificationJSON: VerifiedRegistrationResponse = await (await fetch('/api/auth/login/verify-challenge', {
      method: 'POST',
      credentials: 'same-origin',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(authenticationResponse),
    })).json();

    if (verificationJSON.verified) {
      await invalidateAll();
    }
    else {
      throw new Error(`Verification failed: ${verificationJSON}`);
    }
  }
</script>

<button onclick={login}>ログインする</button>
```
`login()`の中で、上から順に

1. `const { options } = ...`: チャレンジの取得
1. `startAuthentication()`: 認証器に対して認証（署名など）の要求
1. `const verificationJSON: ...`: 認証結果の送信
1. ページデータ(表示)のリフレッシュ

を行っています。

ユーザーからすると、2番のステップのタイミングでパスキーの選択を求められたり、Touch IDやFace IDなどによるパスキーの認証を求められることになります。

# ログアウト
これでログインはできるようになったので、ログアウトの処理も一気に作ってしまいます。

## API
ログアウトで使うAPIは1つだけ & シンプルです。
パスキーとしてはログアウトのためにやることは特にないので、セッション情報を更新するだけになります。
実装は以下のようになります。

```src/routes/api/auth/logout/+server.ts
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';

export const GET: RequestHandler = async ({ locals: { session } }) => {
  session.cookie.path = '/';
  await session.setData({ });
  await session.save();

  return json({ });
};
```

単にセッションの中身を空にしているだけです。

## ブラウザ側のでの処理
最後に、`AuthButton.svelte`コンポーネントでログアウトもできるようにします。
以下の部分を`AuthButton.svelte`に追加します。

```diff_svelte:src/lib/components/AuthButton.svelte
<script lang='ts'>
  import type { VerifiedRegistrationResponse } from '@simplewebauthn/server';
  import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types';
+ import type { InferSelectModel } from 'drizzle-orm';
  import { invalidateAll } from '$app/navigation';
+ import { users } from '$lib/db/schema';
  import { startAuthentication } from '@simplewebauthn/browser';

+ const { user }: { user: InferSelectModel<typeof users> | undefined } = $props();

  async function login() {
    const { options } = await (await fetch(`/api/auth/login`)).json().catch((err) => {
      console.error(err);
    }) as { options: PublicKeyCredentialRequestOptionsJSON | null };
    if (!options)
      return;
    const authenticationResponse = await startAuthentication({
      optionsJSON: options,
    });

    const verificationJSON: VerifiedRegistrationResponse = await (await fetch('/api/auth/login/verify-challenge', {
      method: 'POST',
      credentials: 'same-origin',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(authenticationResponse),
    })).json();

    if (verificationJSON.verified) {
      await invalidateAll();
    }
    else {
      throw new Error(`Verification failed: ${verificationJSON}`);
    }
  }

+ async function logout() {
+   await fetch('/api/auth/logout', {
+     credentials: 'same-origin',
+   });
+   await invalidateAll();
+ }
</script>

+{#if user?.id}
+ <button onclick={logout}>ログアウトする</button>
+{:else}
  <button onclick={login}>ログインする</button>
+{/if}
```
ユーザー情報を引数として受け取るようにし、それがあれば(=ログイン状態であれば)ログアウトボタンを出すようにしています。

`logout()`関数の中では先ほど作成したエンドポイントを叩き、ページデータ(表示)のリフレッシュを行なっています。

# 仕上げ
最後に、`AuthButton.svelte`コンポーネントをトップページに表示してみます。
```diff_svelte:src/routes/+page.svelte
<script lang='ts'>
  import type { LayoutServerData } from './$types';
+ import AuthButton from '$lib/components/AuthButton.svelte';

  const layout: { data: LayoutServerData } = $props();
</script>

{#if layout.data.user}
  {layout.data.user?.name}さん、こんにちは！
{/if}

+ <AuthButton user={layout.data.user} />
```

この状態で再度ローカル開発環境を立ち上げると、以下のようにログイン処理ができるはずです:tada:

<iframe width="560" height="315" src="https://www.youtube.com/embed/rFiIJJCV7wU?si=EEOeEqePYkvBoTPf&amp;controls=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

# デプロイ
ではいよいよこれをCloudflare Workersにデプロイしてみます。
以下のコマンドを実行します。
```bash
bunx wrangler deploy
```
このあとCloudflareのダッシュボードにアクセスすると、以下の箇所でURLを確認できます。
![SS 2024-12-09 0.17.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180518/6a42ec0e-44f7-124e-7998-1144571bb936.png)
ページが正常に動作するには、これをRP IDやオリジンとして環境変数に登録する必要があります。

`.env`を以下のように編集します。

```diff_shell:.env
CLOUDFLARE_ACCOUNT_ID=123456789xxxxxxx123456789xxxxxxx
CLOUDFLARE_DATABASE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
CLOUDFLARE_D1_TOKEN=1234XXXX1234XXXX1234XXXX1234XXXX1234XXXX

- SESSION_SECRET=enough-long-random-string-hoge-fuga

+ PUBLIC_RP_NAME=SvelteKit Passkeys
+ PUBLIC_RP_ID=sveltekit-passkeys.your-account-hoge.workers.dev
+ PUBLIC_ORIGIN=https://sveltekit-passkeys.your-account-hoge.workers.dev
```

:::note info
環境変数の管理について
Gitで管理することや今後もローカルで開発することなどを考えるともうちょっと別の方法をとった方がいいのですが、今は一旦動くことを優先します
:::

また、`SESSION_SECRET`は機密性のある情報なので、以下のコマンドを実行して別途設定します。
```bash
bunx wrangler secret put SESSION_SECRET
```
プロンプトが出るので、シークレットの内容を入力します。

ここまで完了したら、再度デプロイを行います。
```bash
bunx wrangler deploy
```

これで再度ページにアクセスすると、ユーザー登録やログイン / ログアウトが動くはずです！:tada:

:::note info
ローカル開発で作ったユーザーは使えません。
- Cloudflare D1の情報はローカルとサーバー側で同期しない
- パスキーはパスキーに登録されているオリジンと実際のオリジンを比較している

上記の理由により、オリジンが変わるとパスキーも新たに登録する必要があります。
（回避策は色々と考えられそうではありますが）
:::

# おわり！
かなり長くなりましたが、これでSvelteKitでパスキーを使ったユーザー認証ができるようになりました！
ここまでお読みいただいてありがとうございました。

ここで使ったコードの一式はGitHubで公開しているので、ライセンスの範囲で自由に活用してください。

https://github.com/HIBIKI-CUBE/SvelteKit-Prisma-Cloudflare-Passkeys