Remixとexpress-sessionでセッションを共有する

公開日: 

はじめに

現在自分は仕事でNext.jsで運営しているサイトをRemixでリファクタする施策に参加しています。リバースプロキシをたて、同一ドメイン上で古い Next.js サーバーと Remix サーバーにリクエストを振り分けている構成になっています。その施策の中でNext.jsサーバーとRemixサーバーでセッションを共有する必要があったので備忘録として方法を書き残します。

Remix に置き換えるまでの応急処置で、置き換わったら削除されるであろう処理なのでだいぶゴリ押しで実装しています。

やりたいこと

  • /loginにリクエストを送ると既存の Next.js サーバーからレスポンスが返ってくる
  • /hogeにリクエストを送ると Remix サーバーからレスポンスが返ってくる

この時、

  1. ユーザーが Next.js サーバーでホスティングされているログインページでログイン
  2. Next.js でホスティングされている別のページを見にいく ← cookie を用いてセッションが保持されている(ユーザーの認証情報が保持されている)
  3. Remix でホスティングされている/hogeを見にいく ← ここでもセッションが保持されていて欲しい

これがこの記事で達成したいことの概要です。

この記事ではexpress-sessionでセットされた cookie を Remix で読み取る処理を実装します。

前提

  • Next.js 側のセッション管理はexpress-sessionというライブラリを使用(参考)
  • Remix 側では、Remix が用意しているsessionAPIを使用(参考
  • リバースプロキシを通して 2 つのサーバーを立てているのでドメインは同一のものを使用
  • この記事では以下のバージョンを使用
    • express-session: "1.17.3"
    • @remix-run/node: "1.16.1"
    • @remix-run/react: "1.16.1"
    • @remix-run/serve: "1.16.1"

概要

ドメインは同一なのでどちらのサーバーにも cookie の情報は送られてきます。ただ、express-sessionRemixsession APIでは cookie に値を入れるときの処理が異なるため、そのままではお互いがセットした cookie の中身を解読することはできません。なので、間にお互いの処理に互換性を持たせる変換処理を挟んであげます。

上の画像のようなイメージです。

session を cookie で保持する多くの場合、session そのものを cookie で保持するのではなく、ユーザーの情報などが含まれる session の本体は DB などに保存しておき、その session の id などを cookie に保存する場合が多いでしょう。ただ、そうする場合はセキュリティ上の理由で生の id を文字列として保持しておくことは良くなく、基本は暗号化されてブラウザ側に cookie として保存しています。

その暗号化は

  • session の id を暗号化して送信
  • 送信されてきた暗号を解読して session の id を取り出す

これさえできればいいのでライブラリによって暗号化の処理はまちまちです。

今回の例では、express-sesionでセットした cookie を Remix で解読できればいいので、ブラウザから送られてきた cookie に対して以下の処理を行います。

  1. 送られてきた cookie(express-session による暗号化済)に対して、express-session のソースからコピペしてきた処理で解読し、生の id を取り出す
  2. Remix のソースからコピペしてきた方法で暗号化する

これらの処理を行った cookie を Remixsession APIに渡すことで Remix が cookie を解釈することができます。

一旦それぞれの暗号化処理の詳細を見ていきましょう

express-session の処理

https://github.dev/expressjs/session

暗号化

  1. secret の文字列を用いてcookie-signatureというライブラリで暗号化
  2. 暗号化された文字列の先頭にs:という文字列を追加(おそらく、『暗号化されてるよ』っていう意味)
  3. connect.sidという名前で cookie にセット

解読

  1. リクエストヘッダからconnect.sidという名前の cookie 取り出す
  2. 先頭のs:の文字を削る
  3. secret 文字列を用いてcookie-signatureで解読

Remix の session API の暗号化処理

https://github.com/remix-run/remix/blob/main/packages/remix-server-runtime/cookies.ts

暗号化

  1. Remix 独自の encode 処理をする(参照
  2. secret 文字列を用いてcookie-signatureで暗号化
  3. 指定された名前で cookie にセット

解読

  1. リクエストヘッダから指定された名前の cookie 取り出し、cookie.parseで cookie 文字列からオブジェクトに変換
  2. secret 文字列を用いてcookie-signatureで解読
  3. オリジナルの decode 処理をする(参照)

最終的な実装

以下のコードは Remix が提供する loader 関数 です https://remix.run/docs/en/main/route/loader

cookie にユーザー情報があればユーザーを取得してコンポーネントに渡しています。

export async function loader({ request }: LoaderArgs) {
  // リクエストのヘッダからcookieを取り出す
  const cookie = request.headers.get('Cookie');

  // express-session => Remixの変換処理
  const compatibleCookie = getCompatibleCookies(cookie);

  // session idからユーザーを取得
  const user = await getSessionUser(compatibleCookie);

  return json({
    user,
  });
}

最も重要な変換処理のコードは以下です。

import { parse } from 'cookie';
import { unsign, sign } from 'cookie-signature';

// express-session側でデフォルトになっているcookieの名前
const COOKIE_NAME = 'connect.sid';

// 暗号化に必要なシークレット文字列
const COOKIE_SECRET = 'hoge';

/**
 * copied from https://github.com/remix-run/remix/blob/main/packages/remix-server-runtime/cookies.ts#L223
 * Remixのソースからコピーしてきた処理
 */
function myUnescape(value: string): string {
  const str = value.toString();
  let result = '';
  let index = 0;
  let chr;
  let part;
  while (index < str.length) {
    chr = str.charAt(index++);
    if (chr === '%') {
      if (str.charAt(index) === 'u') {
        part = str.slice(index + 1, index + 5);
        if (/^[\da-f]{4}$/i.exec(part)) {
          result += String.fromCharCode(parseInt(part, 16));
          index += 5;
          continue;
        }
      } else {
        part = str.slice(index, index + 2);
        if (/^[\da-f]{2}$/i.exec(part)) {
          result += String.fromCharCode(parseInt(part, 16));
          index += 2;
          continue;
        }
      }
    }
    result += chr;
  }
  return result;
}

/**
 * copied from https://github.com/remix-run/remix/blob/main/packages/remix-server-runtime/cookies.ts#L182
 * Remixからコピーしてきた処理
 */
function encodeData(value: string | boolean): string {
  return btoa(myUnescape(encodeURIComponent(JSON.stringify(value))));
}

/**
 * 変換処理の本体
 * express-sessionの処理でcookieを解読して生の値を取り出した上でRemixの処理で暗号化する関数
 */
export const getCompatibleCookies = (
  cookieStr: string | null,
): string | null => {
  if (cookieStr == null) return null;

  // cookie文字列から対象となる値を取得
  const cookieObj = parse(cookieStr);
  const sessionIdFromCookie = cookieObj[COOKIE_NAME];

  if (sessionIdFromCookie == null) return null;

  // 取り出した文字列から's:'の文字を取り除いて、解読
  const rawValue = unsign(sessionIdFromCookie.slice(2), COOKIE_SECRET);

  // Remixの処理で暗号化
  const encodedData = encodeData(rawValue);
  const signedValue = sign(encodedData, COOKIE_SECRET);
  // https://regex101.com/r/SXYDhc/1
  const regExp = new RegExp(`(?<=${COOKIE_NAME}=)(.*?)(?:;|$)`);
  const replacedCookieString = cookieStr.replace(regExp, signedValue);

  return replacedCookieString;
};

これが先ほど詳細を書いた変換処理です。この関数を挟むことによってexpress-sessionRemixsession API間でセッションの共有ができるようになります。

よければ参考にしてみてください。

最後に

割と大変な要件で、ゴリ押しの実装ではありましたが、完全に Remix に置き換わるまでの応急処置だということを考えれば許せるかなと思います。ライブラリの中身を読んでそのコードを元に色々手を加えるというあるハッカーのような体験ができて面白かったです。

では

Bye