next-sessionでセッションを再生成するときの注意点

next-sessionはNext.jsでのsession利用を簡単化するためのライブラリです。
主に以下を行ってくれます。
- session cookieの管理
- session storeとの接続 (session idの突合）
- HTTPヘッダーの設定、Storeへの書き込みを暗黙的に行うautoCommit機能

参考: [hoangvvo/next-session（github)](https://github.com/hoangvvo/next-session)

この記事では、セッションを再生成する実装を行う際に私がハマったことについて共有します。
先に書いておくと、`autoCommit: false`を指定した場合は同じ原因でハマることはありません。

## どういう時にセッションを再生成するのか
セキュリティの観点から、ログイン成功時に新しいセッションを開始し古いセッションを破棄することが望ましいです。
これは、IPAのセキュリティガイドにも記載されています。

参考: [1.4 セッション管理の不備　](https://www.ipa.go.jp/security/vuln/websecurity/session-management.html)
※ `4-(iv)-a ログイン成功後に、新しくセッションを開始する`を参照

## next-sessionにおけるsessionの再生成
next-sessionには１つの関数呼び出しでセッションを再生成できる様なAPIはありません。
（[express-session](https://www.npmjs.com/package/express-session)には`session.regenerate()`というAPIがありました）

そのため、単純ですが以下の順序で命令を書く必要があります。
1. SessionStoreからセッションを取得
2. セッションを破棄
3. 新たなセッションを生成

## 何に気をつけるべきなのか
気をつけるべきなのは、autoCommit機能を使用している場合です。
（デフォルトは有効）

### autoCommitとは
next-sessionには`session.commit()`というメソッドがあり、sessionに情報を追加したり、sessionを生成した場合はその後にcommit()を実行しなければSessionStoreやブラウザのcookieに反映されません。

毎回忘れず`session.commit()`を実行するのは面倒なので、用意されているのがautoCommit機能です。
名前の通り、`commit()`を毎回書く必要がなくなり、getServerSideProps終了のタイミングなど、リクエストに対するサーバーサイドの処理が終了するタイミングで自動的にcommitしてくれる様になります。

### 起こる問題
session取得を行うタイミングでautoCommitフラグを指定します。
```typescript
import nextSession from "next-session";
const getSession = nextSession({ autoCommit: true });
// autoCommit: trueが規定値なので有効にしたいなら指定は実は不要

export default function handler(req, res) {
  // sessionを取得するタイミングでautoCommitの処理が予約される
  const session = await getSession(req, res);
}
```

sessionを取得するタイミングで、autoCommitの処理が予約されます。
「予約」というのをもう少し詳細に説明すると、Next.jsの`res.end()`と`res.writHead()`を拡張し、commitの処理を追加します。

これ自体は問題ではありませんが、sessionの再生成を行う際は問題が起こります。

1. getSessionを実行し、既存セッション①をSessionStoreから取得
2. `session.destory()`でセッション①を破棄
3. 再度getSessionを実行し、新規セッション②を生成する

この手順の１と３で**autoCommit処理の予約が2回行われてしまいます**。
この手順でログイン処理を行ってトップページに戻すページコンポーネントの例は以下です。
```typescript
import { GetServerSideProps } from "next";

const Page = () => {};

const getSession = nextSession({ autoCommit: true });

// うまく動かないコードの例
export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
  const session = await getSession(req, res);
  await session.destroy();
  const session2 = await getSession(req, res);
  session2.status = "logged in"; // ログイン処理

  return {
    redirect: {
      destination: "/top",
      permanent: true,
    },
  };
};
```

このコードを実行すると以下の様なエラーがconsoleに表示され、`session2`のsession idはブラウザにcookieとして保存されずログイン状態になることはありません。
```
⚠ You should not access 'res' after getServerSideProps resolves.
Read more: https://nextjs.org/docs/messages/gssp-no-mutating-res
```

### 対処法
#### ① autoCommitを無効にする
簡単な対処法として、autoCommitを無効にすれば良いです。
例えば、ログイン処理を行ってトップページに戻すページコンポーネントの例は以下です。

```typescript
import { GetServerSideProps } from "next";

const Page = () => {};

const getSession = nextSession({ autoCommit: false });

export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
  const session = await getSession(req, res);
  await session.destroy();
  const session2 = await getSession(req, res);
  session2.status = "logged in"; // ログイン処理
  await session2.commit();

  return {
    redirect: {
      destination: "/top",
      permanent: true,
    },
  };
};
```

autoCommitを無効化したので、手動でcommitメソッドを実行してください。

### ②ログインの時だけautoCommitを無効にする
セッションの再生成を行うタイミングは限られており、そのためにシステム全体でautoCommitを無効にしなくてはならないのでは制約が大きいです。
できれば、ログイン処理のみautoCommitを無効にしたいです。

実現するのはそれほど難しくありませんが、以下のポイントに注意すべきです。
- `autoCommit: true`と`autoCommit: false`のgetSessionをそれぞれ用意すること
- 有効/無効のgetSessionそれぞれで同じSessionStoreと接続すること

next-sessionのautoCommitの有無はセッション取得時に選択することはできません。
getSessionを生成するFactory関数を実行する際に指定する必要があるため、Next.jsのアプリを起動したタイミングで有効/無効それぞれのgetSessionを生成しておく必要があります。

```typescript
export const getSessionAutoCommit = nextSession({ autoCommit: true });
export const getSessionManualCommit = nextSession({ autoCommit: false });
// ※このコードでもまだ動かない。２つ目の注意点を考慮する必要がある。
```

そして、２つ目の注意点はSessionStoreです。
next-sessionではオプションにstoreを指定しなかった場合、備え付けのMemoryStoreを使用します。
このMemoryStoreは`nextSession()`を実行するたびに、**それぞれのgetSessionに対して別のインスタンスを生成します。** つまり、上記の例では`getSessionManualCommit`で生成したセッションは`getSessionAutoCommit`で参照することができません。

この対処として、それぞれで共有するSessionStoreを用意する必要があります。

```typescript
// next-sessionの組み込みのMemoryStoreのコードをコピペして使用している
import MemoryStore from "./memory-store";

const store = new MemoryStore();

export const getSessionAutoCommit = nextSession({ autoCommit: true, store });
export const getSessionManualCommit = nextSession({ autoCommit: false, store });
```

こうすることで、ログイン処理では`getSessionManualCommit`を使用し、他の処理では`getSessionAutoCommit`を使用することができます。

本格的なアプリケーションであれば、共有のstoreはRedisStoreなどを使うことが多いと思います。
(例: [connect-redis](https://github.com/tj/connect-redis))
もし、MemoryStoreを使用したい場合は[next-sessionの備え付けのMemoryStore](https://github.com/hoangvvo/next-session/blob/master/src/memory-store.tsのコード)をコピーして使用することで動作させることもできます。

## 終わりに
sessionの再生成は多くのアプリで実装が必要そうな機能ですが、next-sessionを使用した例が私がWeb検索した限りでは見つからなかったので今回の記事を書きました。
`autoCommit: true`で再生成しようとしてしまったときのエラーが分かりづらく、原因調査が難しかったこともあり、誰かの助けになれば良いと思いました。