はじめに
こんにちは、梅雨です。
今回は、エッジランタイムで JWT を取り扱い、認証を行う方法について解説していきます。
エッジランタイムとは?
エッジランタイム(Edge Runtime) とは、エッジコンピューティング環境での実行を目的とした JavaScript の実行環境であり、世界中に配置されているエッジサーバでソースコードを低レイテンシで実行することができます。
エッジランタイムには Cloudflare Workers
や AWS Lambda@Edge
などいくつか種類があり、プラットフォームによって異なるランタイムが提供されています。
この先、この記事では Next.js て使用することができる、Vercel の提供しているエッジランタイムについて解説していきます。
各プラットフォームでは実装されている API が異なるため、すべてのエッジランタイムで同じ記述方法ができるとは限らないことに注意してください。
Vercel のエッジランタイムはエンジンとして JavaScript V8
を採用しているため JavaScript の(というか ECMAScript の) 標準 API を使用することはできますが、Node.js とは異なるランタイムであるため、fs
や http
などの Node.js の API を使用することはできません。
Next.js ではミドルウェアがエッジランタイムで実行されます。
JWT の前提知識
次に、この記事のメインテーマとなる JWT の前提知識に触れておこうと思います。JWT(JSON Web Token)は、JSON形式のデータを使って情報を安全に伝達するためオープンスタンダード仕様であり、トークンに署名を施すことでその内容の改竄を防ぎ、信頼性を確保しています。
JWT の標準仕様は RFC 7519 に記述されています。
JWT はヘッダ、ペイロード、署名と呼ばれる3つの部分から構成されており、これらを .
で繋げた文字列が1つのトークンとして扱われます。
ヘッダ
ヘッダ部分には、トークンのタイプ(通常はJWT)と使用される署名アルゴリズム(例えば、HS256やRS256など)を指定します。
{
"alg": "HS256",
"typ": "JWT"
}
ペイロード
ペイロード部分には、ユーザのIDやトークンの発行日時(iat)、有効期限(exp)など、さまざまなクレーム(ユーザに関する情報)を含めることができます。
{
"sub": "1234567890",
"name": "Meiyu",
"iat": 1734683173
}
注意として、ペイロードは暗号化されていないためユーザの個人情報などを含めるべきではありません。IDやユーザ名など、他のユーザに取得されても問題のない情報のみを含めましょう。
署名
署名部分には、ヘッダとペイロードを指定されたアルゴリズムを使って生成されたハッシュ値を記述します。
これはハッシュ化であっても暗号化ではないので、元のデータを解読するような方法はありません。
このように ヘッダ + ペイロード
→ ハッシュ値
の変換は不可逆であるため、ヘッダとペイロードのどちらかが改竄されている場合ハッシュ値が一致せず改竄に気づくことができます。
エッジランタイムで JWT を取り扱う
ついに本題のエッジランタイムで JWT を取り扱う方法についてです。
JWT の生成
今回は例として以下の JWT を検証することを考えてみましょう。
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 モジュールを使用しました。
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
が存在しない場合はリクエストを弾くことができます。
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
ライブラリを使用します。
しかし、先ほども述べたようにエッジランタイムは Node.js のランタイムと異なるため、このライブラリを使用することができません。
そこで出てくるのが jose
ライブラリです。このライブラリは驚くことに依存パッケージが 0 で、Vercel の Edge Runtime のサポートも明記されています。
今回は jose
を用いて JWT の検証を行っていきましょう。
まず、公開鍵が記述されている pem ファイルを public ディレクトリ内に置きます。そうすると以下のように公開鍵を取得することができるようになります。
const res = await fetch("http://localhost:3000/public.pem");
const publicKey = await res.text();
続いて、公開鍵を用いて JWT を検証しましょう。jwtVerify
関数にトークンとインポートした公開鍵を渡すことで検証ができます。
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
で囲ってあげましょう。
最終的なミドルウェアのコードは以下のようになりました。
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 のランタイムとは異なることに留意して開発するようにしましょう。
最後までお読みいただきありがとうございました。