SvelteKitにおいてストアを利用したセッション管理を行う svelte-kit-sessions

## はじめに

SvelteKitでのセッション管理のライブラリを探してみると、どうやらCookieにセッション情報を保存するようなものや、ストアがRedisに固定化されているものしかないようだった（探し方が悪いのかもしれないが…）。

個人的には[Express](https://expressjs.com/ja/)での開発経験があり、その時に利用していた[express-session](https://www.npmjs.com/package/express-session)のように自由にストアを選べ、セッション情報はストアに保存するようなセッション管理のモジュールで、SvelteKitでのセッション管理も実現されると望ましいのではと考えていた。

そこで、`svelte-kit-sessions`というストアの利用を前提としたセッション管理モジュールを開発・公開してみたので、今回はそれを利用してどのようにセッション管理を実装できるか？を取り上げたいと思う。

https://www.npmjs.com/package/svelte-kit-sessions

## [svelte-kit-sessions](https://www.npmjs.com/package/svelte-kit-sessions)の概要

[svelte-kit-sessions](https://www.npmjs.com/package/svelte-kit-sessions)は、CookieにセッションID（+署名）を焼き、そのCookieのセッションIDをストアのキーにしてセッション情報を保存し、セッションがあればストアから保存されているセッション情報を取りだし、SvelteKitの[Actions](https://kit.svelte.jp/docs/form-actions)や[API routes](https://kit.svelte.jp/docs/routing#server)、[Server hooks(handle)](https://kit.svelte.jp/docs/hooks#server-hooks)で利用できるようにするモジュール。

つまりは、以下のような図に描かれていることを、SvelteKitで簡単に実現することができるモジュール。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/e8cb340d-4c65-6845-488e-ae0f62f77d1b.png)

具体的な利用方法はREADMEの[Usage](https://www.npmjs.com/package/svelte-kit-sessions#usage)セクションに記載しているが、それぞれ以下のような使用感になる。

### Actions

ユーザーの認証を行い、セッションを作成する。

```ts:src/routes/login/+page.server.ts
import type { ServerLoad, Actions } from '@sveltejs/kit';
import db from '$lib/server/db.ts';

export const load: ServerLoad = async ({ locals }) => {
	const { session } = locals; // you can access `locals.session`
	const user = await db.getUserFromId(session.data.userId);
	return { user };
};

export const actions: Actions = {
	login: async ({ request, locals }) => {
		const { session } = locals; // you can access `locals.session`

		const data = await request.formData();
		const email = data.get('email');
		const password = data.get('password');
		const user = await db.getUser(email, password);

		await session.setData({ userId: user.id, name: user.name }); // set data to session
		await session.save(); // session saveand session create(session data is stored and set-cookie)

		return { success: true };
	},
	...
};
```

### API route

セッションを持っているユーザーでTODOを作成する際に、TODOの作成者をセッションの`userId`にして作成する。

```ts:src/routes/api/todo/+server.ts
import { json, type RequestEvent, type RequestHandler } from '@sveltejs/kit';
import db from '$lib/server/db.ts';

interface TodoBody {
	title: string;
	memo: string;
}

export const POST: RequestHandler = async (event: RequestEvent) => {
	const { session } = event.locals; // you can access `event.locals.session`

	const { title, memo } = (await event.request.json()) as TodoBody;
	const todoId = await db.createTodo({ title, memo, userId: session.data.userId });

	return json({ id: todoId }, { status: 200 });
};
```

### Server hooks(handle)

認証後のセッションを持っていないアクセスの場合、ログイン画面にリダイレクトする。

```ts:src/hooks.server.ts
import { redirect, type Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { sveltekitSessionHandle } from 'svelte-kit-sessions';
import RedisStore from 'svelte-kit-connect-redis';
import { Redis } from 'ioredis';

const client = new Redis({
	host: '{your redis host}',
	port: 6379
});

const checkAuthorizationHandle: Handle = async ({ event, resolve }) => {
	// `event.locals.session` is available
	if (!event.locals.session.data.userId) redirect(307, '/login');

	const result = await resolve(event);
	return result;
};

export const handle: Handle = sequence(
	sveltekitSessionHandle({ secret: 'secret', store: new RedisStore({ client }) }),
	checkAuthorizationHandle
);
```

- 参考：https://kit.svelte.jp/docs/load#redirects

## [svelte-kit-sessions](https://www.npmjs.com/package/svelte-kit-sessions)の機能・ポイント

[svelte-kit-sessions](https://www.npmjs.com/package/svelte-kit-sessions)には、以下のような機能・ポイントがある（どれもないと困る当たり前といえば当たり前の機能なのだが…）。

- 自由にストアを選択できる
- セッションの設定が柔軟にできる
  - 初期化せずセッションを作成できる
  - セッションのexpire（期限）を自動で更新できる
  - セッションハイジャック防止のためのセッションの再生成ができる
  - Cookieの署名シークレットを複数指定でき、シークレットローテーションができる 

### 自由にストアを選択できる

[Compatible Session Stores](https://www.npmjs.com/package/svelte-kit-sessions#compatible-session-stores)というセクションに複数のストアが列挙されているように、セッションのストアは自由に選択できる。

[Compatible Session Stores](https://www.npmjs.com/package/svelte-kit-sessions#compatible-session-stores)に載っていないストアでも、[Session Store Implementation](https://www.npmjs.com/package/svelte-kit-sessions#session-store-implementation)のセクションに書かれているインターフェースを満たすクラスを実装すれば、独自のストアを利用することも可能になっている。

### セッションの設定が柔軟にできる

#### 初期化せずセッションを作成できる

[saveUninitialized](https://www.npmjs.com/package/svelte-kit-sessions#saveuninitialized)というオプションを`true`にすると、svelte-kit-sessionsの方で自動的にセッションを作成するように設定できる。

ログインを行い、初めてセッションを払い出すようなパターンではこのオプションは`false`にすべきだが、そうでなくアクセスがあったら即セッションを払い出す場合には便利なオプション。

#### セッションのexpire（期限）を自動で更新できる

[rolling](https://www.npmjs.com/package/svelte-kit-sessions#rolling)というオプションを`true`にすると、自動的にcookieのオプションである`maxAge`の時間だけセッションの有効期限を延ばすように設定できる。

#### セッションハイジャック防止のためのセッションの再生成ができる

セッションハイジャックの防止のために、ログイン前とログイン後のセッションは別のものにする必要があるが、[session.regenerate()](https://www.npmjs.com/package/svelte-kit-sessions#sessionregenerate)というメソッドにより簡単に実装できる。

例えば、OpenID Connectによる認証を行う際の`state`や`codeVerifier`などを保存するセッションと、callback後のログイン後のセッションは別ものにすべきだが、その場合には以下のような実装ができるだろう（ここで言うOpenID ConnectのフローはAuthorization Code Flowで、scopeに`openid`, `profile`など指定してIDトークン＋ユーザー情報をもらうような想定）。

```ts:src/hooks.server.ts
import { redirect, type Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { sveltekitSessionHandle } from 'svelte-kit-sessions';
import RedisStore from 'svelte-kit-connect-redis';
import { Redis } from 'ioredis';
import oauthClient from '$lib/server/oauth-client.js'; // OpenID Connect(OAuth2.0)のためのライブラリとする

const client = new Redis({
	host: '{your redis host}',
	port: 6379
});

const checkAuthHandle: Handle = async ({ event, resolve }) => {
    // callbackのエンドポイント、一時トークンを利用してIDトークンなどを取得する
	if (event.url.pathname === '/oauth/callback' && event.request.method === 'GET') {
        // `event.locals.session` is available
		if (event.locals.session.data.state !== event.params.state) throw Error('Invalid state.');

		const data = await oauthClient.callback({
			request: event.request,
			state: event.locals.session.data.state,
			codeVerifier: event.locals.session.data.codeVerifier
		});

		const newSession = await session.regenerate();
		await newSession.setData({ userId: data.sub, email: data.email, name: data.name });
		await newSession.save();
		throw redirect(302, '/');
	}

	// セッションがないのでAuthorization Code Flowを開始する
	if (!event.locals.session.data.userId) { 
		const { authUri, state, codeVerifier } = oauthClient.start();
		await event.locals.session.setData({ state, codeVerifier });
		await event.locals.session.save();
		throw redirect(302, authUri);
	}

	const result = await resolve(event);
	return result;
};

export const handle: Handle = sequence(
	sveltekitSessionHandle({ secret: 'secret', store: new RedisStore({ client }) }),
	checkAuthHandle
);
```

※上記はサンプルコードとして`hooks.server.ts`にAuthorization Code Flowの実装をしているが、実際にはちゃんとAPI Rotesなどに切り出すべきだろう。

#### Cookieの署名シークレットを複数指定でき、シークレットローテーションができる

[secret](https://www.npmjs.com/package/svelte-kit-sessions#secret)というオプションには文字列の他に、文字列の配列を設定できるようになっており、Cookieの署名シークレットを定期的に更新しつつ、既存のCookieが無効にならないようにできる。

具体的には、secretに`["secret2", "secret1"]`のように設定することで、もともと利用していた`"secret1"`を無効にせずに新しい`"secret2"`というシークレットを追加できるようになっている。複数シークレットが設定されている場合、Cookieの署名で使用されるシークレットは配列の1番目のシークレットになる。

## まとめとして

`svelte-kit-sessions`というSvelteKitでストアの利用を前提としたセッション管理モジュールについて、その機能・ポイントについてまとめてみた。

SvelteKitの開発でセッション管理の仕組みを組み込みたいときには、[svelte-kit-sessions](https://www.npmjs.com/package/svelte-kit-sessions)が便利だと思うので、利用頂いてどなたかの役に立てば幸いです。