【TypeScript】エッジランタイムで JWT を取り扱う

# はじめに

こんにちは、梅雨です。

今回は、エッジランタイムで JWT を取り扱い、認証を行う方法について解説していきます。

# エッジランタイムとは？

**エッジランタイム（Edge Runtime）** とは、エッジコンピューティング環境での実行を目的とした JavaScript の実行環境であり、世界中に配置されているエッジサーバでソースコードを低レイテンシで実行することができます。

エッジランタイムには `Cloudflare Workers` や `AWS Lambda@Edge` などいくつか種類があり、プラットフォームによって異なるランタイムが提供されています。

この先、この記事では Next.js て使用することができる、Vercel の提供しているエッジランタイムについて解説していきます。

https://edge-runtime.vercel.app

:::note warn
各プラットフォームでは実装されている API が異なるため、すべてのエッジランタイムで同じ記述方法ができるとは限らないことに注意してください。
:::

Vercel のエッジランタイムはエンジンとして `JavaScript V8` を採用しているため JavaScript の（というか ECMAScript の） 標準 API を使用することはできますが、Node.js とは異なるランタイムであるため、`fs` や `http` などの Node.js の API を使用することはできません。

Next.js ではミドルウェアがエッジランタイムで実行されます。

https://nextjs.org/docs/app/building-your-application/routing/middleware

# JWT の前提知識

次に、この記事のメインテーマとなる **JWT** の前提知識に触れておこうと思います。JWT（JSON Web Token）は、JSON形式のデータを使って情報を安全に伝達するためオープンスタンダード仕様であり、トークンに署名を施すことでその内容の改竄を防ぎ、信頼性を確保しています。

JWT の標準仕様は RFC 7519 に記述されています。

https://datatracker.ietf.org/doc/html/rfc7519

JWT は**ヘッダ**、**ペイロード**、**署名**と呼ばれる３つの部分から構成されており、これらを `.` で繋げた文字列が１つのトークンとして扱われます。

### ヘッダ

ヘッダ部分には、トークンのタイプ（通常はJWT）と使用される署名アルゴリズム（例えば、HS256やRS256など）を指定します。

```json
{
  "alg": "HS256",
  "typ": "JWT"
}
```

### ペイロード

ペイロード部分には、ユーザのIDやトークンの発行日時（iat）、有効期限（exp）など、さまざまなクレーム（ユーザに関する情報）を含めることができます。

```json
{
  "sub": "1234567890",
  "name": "Meiyu",
  "iat": 1734683173
}
```

:::note warn
注意として、ペイロードは暗号化されていないためユーザの個人情報などを含めるべきではありません。IDやユーザ名など、他のユーザに取得されても問題のない情報のみを含めましょう。
:::

### 署名

署名部分には、ヘッダとペイロードを指定されたアルゴリズムを使って生成されたハッシュ値を記述します。

これはハッシュ化であっても暗号化ではないので、元のデータを解読するような方法はありません。

このように `ヘッダ + ペイロード` → `ハッシュ値` の変換は不可逆であるため、ヘッダとペイロードのどちらかが改竄されている場合ハッシュ値が一致せず改竄に気づくことができます。

# エッジランタイムで JWT を取り扱う

ついに本題のエッジランタイムで JWT を取り扱う方法についてです。

## JWT の生成

今回は例として以下の JWT を検証することを考えてみましょう。

```bash
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik1laXl1Iiw
iaWF0IjoxNzM0NjgzMTczfQ.YAtKRtBvW97wjc3CMpbjj9loLnUrbvySuKHPEtOnwt0yHgDx0jEyvHKxVzuA
8ofM0gYcpE02PH_ZyOv4OOLgu_iiH_zTkAZdKb1RlFaVBddDerylLNp3gZaagOQ6YuDaKk3R_lDle1pXEE55
UEQ_W_pX9nkX6MJVfHaY2C5KiPxYdqLF_1CSEWRIAAwF4c4CusBw5PLH6l-8iU6A3_UsCXsfjUSFsPB2a2io
eONIKb1Y73aQRqvs27rFBbfBTR3dZJmHAcIp9wJvtATd2QOkp0JGSaUiuQNtsMLCiv6DRGYArAItBk6EIm6W
QYvWYaKy_v0TJTpbaT0TRFvlBFFnWbxGPBfFvMOWfth06rzrP6UL1hH7kDaAS1WoZtDqfdFSUNQIWMwSWozG
ByBmGTPaen5VPpKD0z48cD9PYHBpAc5NHfM9ZubkdrX6dIPQDwcL32ow4hawGeF0nTnWiln_kzg3cY3M1zbN
tvTy-XeTUIzrqKq5DDKjokQVPYiXDESP
```

JWT および鍵の生成には Node.js の Crypto モジュールを使用しました。

```ts
import crypto from "crypto";

const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", {
  modulusLength: 3072,
  privateKeyEncoding: {
    type: "pkcs8",
    format: "pem",
  },
  publicKeyEncoding: {
    type: "spki",
    format: "pem",
  },
});

const header = {
  alg: "RS256",
  typ: "JWT",
};

const payload = {
  sub: "1234567890",
  name: "Meiyu",
  iat: 1734683173,
};

const encodedHeader = Buffer.from(JSON.stringify(header))
  .toString("base64")
  .replace(/\+/g, "-")
  .replace(/\//g, "_")
  .replace(/=+$/g, "");

const encodedPayload = Buffer.from(JSON.stringify(payload))
  .toString("base64")
  .replace(/\+/g, "-")
  .replace(/\//g, "_")
  .replace(/=+$/g, "");

const encodedHeaderPayload = `${encodedHeader}.${encodedPayload}`;

const signer = crypto.createSign("RSA-SHA256");
signer.update(encodedHeaderPayload);
const signature = signer
  .sign(privateKey, "base64")
  .replace(/\+/g, "-")
  .replace(/\//g, "_")
  .replace(/=+$/g, "");

const jwt = `${encodedHeaderPayload}.${signature}`;

console.log(`JWT:\n${jwt}\n\nPublicKey:\n${publicKey}`);
```

この JWT はクライアントのブラウザに `auth_token` という名前の HttpOnly Cookie で保存されているとします。

## JWT の取得

Next.js のミドルウェアでは以下のように Cookie を取得し、`auth_token` が存在しない場合はリクエストを弾くことができます。

```ts:src/middleware.ts
import { NextResponse, type NextRequest } from "next/server";

const middleware = (req: NextRequest) => {
  const auth_token = req.cookies.get("auth_token")?.value;

  if (!auth_token) {
    req.nextUrl.pathname = "/login";
    return NextResponse.redirect(req.nextUrl);
  }
};

export const config = {
  matcher:
    "/((?!login|api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
};

export default middleware;
```

しかし、これだけでは JWT の内容が正しいかどうかや、そのユーザにアクセス権限があるかどうかを判定することまではできていません。

## JWT の検証

ここでやっと本題となるエッジランタイムでの JWT の取り扱いについて話していきます。

Node.js 環境で JWT を扱う際、普通は `jsonwebtoken` ライブラリを使用します。

https://www.npmjs.com/package/jsonwebtoken

しかし、先ほども述べたようにエッジランタイムは Node.js のランタイムと異なるため、このライブラリを使用することができません。

そこで出てくるのが `jose` ライブラリです。このライブラリは驚くことに依存パッケージが 0 で、Vercel の Edge Runtime のサポートも明記されています。

https://www.npmjs.com/package/jose

今回は `jose` を用いて JWT の検証を行っていきましょう。

まず、公開鍵が記述されている pem ファイルを public ディレクトリ内に置きます。そうすると以下のように公開鍵を取得することができるようになります。

```ts
const res = await fetch("http://localhost:3000/public.pem");
const publicKey = await res.text();
```

続いて、公開鍵を用いて JWT を検証しましょう。`jwtVerify` 関数にトークンとインポートした公開鍵を渡すことで検証ができます。

```ts
const res = await fetch("http://localhost:3000/public.pem");
const publicKey = await res.text();
const spki = await importSPKI(publicKey, "RS256");

const auth_token = req.cookies.get("auth_token")?.value;

const { payload } = await jwtVerify(auth_token, spki);

console.log(payload);
```

この`jwtVerify` 関数は検証に失敗した場合エラーを throw するため、`try-catch` で囲ってあげましょう。

最終的なミドルウェアのコードは以下のようになりました。

```ts:src/middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { importSPKI, jwtVerify } from "jose";

const middleware = async (req: NextRequest) => {
  // SPKI形式の公開鍵をインポート
  const res = await fetch("http://localhost:3000/public.pem");
  const publicKey = await res.text();
  const spki = await importSPKI(publicKey, "RS256");

  // Cookieからauth_tokenを取得
  const auth_token = req.cookies.get("auth_token")?.value;

  // auth_tokenがなければログイン画面へリダイレクト
  if (!auth_token) {
    req.nextUrl.pathname = "/login";
    return NextResponse.redirect(req.nextUrl);
  }

  try {
    // JWTを検証
    const { payload } = await jwtVerify(auth_token, spki);
    console.log(payload);
  } catch (err) {
    // 検証に失敗したらCookieを削除してログイン画面へリダイレクト
    req.nextUrl.pathname = "/login";
    const res = NextResponse.redirect(req.nextUrl);
    res.cookies.delete("auth_token");
    return res;
  }
};
export const config = {
  matcher:
    "/((?!login|public.pem|api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
};

export default middleware;
```

以上で JWT を検証し、ペイロードを取得することができました。

# おわりに

Web アプリケーションは年々高速化が進んでおり、エッジコンピューティングを活用した開発は今後も必要なスキルになっていくかもしれません。

その際は、エッジランタイムは Node.js のランタイムとは異なることに留意して開発するようにしましょう。

最後までお読みいただきありがとうございました。