🛠

Next.js と iron-session でアクセストークンを安全に保存する

に公開

こんにちは。レンティオでエンジニアをしている小島です。

弊社のあるプロダクトではフロントエンドを Next.js で作り、バックエンドの API を Rails で作っています。
これには以下のような (よくある) ユーザーのログイン機能があります。

API がアクセストークンを発行する

  • ブラウザで入力された email, password をログインのエンドポイントに送信
  • ログイン成功のレスポンスでアクセストークンが渡される
  • その後の API コールにはアクセストークンを含める
    • Authorization: Bearer <access-token>

アクセストークンをどこに保存するか

ログイン後はアクセストークンをブラウザに保存したいですが、どうすればよいでしょうか?

Cookie やローカルストレージに保存するには問題があります。
JavaScript で読み取って使うことを前提にしているため、XSS によって攻撃者にも読み取られる危険があります。

iron-session による解決

結論、Next.js と iron-session の組み合わせで以下の構成にできます。

  • ブラウザは必ず BFF (Backend for Frontend) 経由で API コールする
  • 生のアクセストークンを扱うのは BFF の役割
  • ブラウザは BFF で暗号化されたデータを Cookie に保存する
    • それには HttpOnly 属性が付き、JavaScript で読み取れない
    • BFF とは同じドメインのため Cookie, Set-Cookie ヘッダのやりとりができる

以下で詳細を見ていきましょう。

iron-session の設定

iron-session で扱うデータの型と、ブラウザに保存される Cookie について設定します。

sample-session.ts
import { SessionOptions } from "iron-session"

export type SessionData = {
  accessToken?: string
  // 他にも機密情報があれば詰め込める
}

export const sessionOptions: SessionOptions = {
  cookieName: "sample-session",
  password: process.env.SAMPLE_SESSION_PASSWORD!,
}

ここでは Cookie の名前を sample-session とします。
また、iron-session がデータを暗号化・復号するのに使うパスワードを設定します。
そのほかは設定しなくても HttpOnly, Secure 属性が付くなど安全寄りになっています。

ログイン処理

BFF

API Routes にログインのエンドポイントを作成し、以下を実装します。

  • ブラウザから送信された email, password を API に受け渡す
  • 発行されたアクセストークンを暗号化したデータをブラウザに返す
pages/api/login.ts
import { getIronSession } from "iron-session"
import { NextApiRequest, NextApiResponse } from "next"

import { SessionData, sessionOptions } from "@/sample-session"

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const session = await getIronSession<SessionData>(req, res, sessionOptions)

  // API にリクエスト
  const { email, password } = req.body
  const response = await fetch("https://api.example.com/login", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email, password }),
  })

  // アクセストークンを暗号化したデータを Set-Cookie ヘッダでブラウザに返す
  session.accessToken = response.headers.get("Access-Token")!
  await session.save()
  res.status(200).json({})
}
App Router の場合

README に例があるように、Route Handlers でもほとんど同じように書けます。

app/api/login/route.ts
import { getIronSession } from "iron-session"
import { cookies } from "next/headers"

import { SessionData, sessionOptions } from "@/sample-session"

export async function POST() {
  const session = await getIronSession<SessionData>(cookies(), sessionOptions)

  // API にリクエスト
  const { email, password } = req.body
  const response = await fetch("https://api.example.com/login", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email, password }),
  })

  // アクセストークンを暗号化したデータを Set-Cookie ヘッダでブラウザに返す
  session.accessToken = response.headers.get("Access-Token")!
  await session.save()
  return Response.json({})
}

ブラウザ

ログインページから上記の API Routes を呼び出します。

pages/login.tsx
// BFF にリクエスト
const response = await fetch("/api/login", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email, password }),
})

このレスポンスの Set-Cookie ヘッダによって sample-session Cookie が保存されます。
HttpOnly 属性が付いているのを確認できます。

保存された Cookie

ログイン以外の処理

BFF

ログイン以外の API コールにはアクセストークンを含める実装をしたいのですが、各エンドポイントについて API Routes を足していくのは大変です。
そこで、あらゆるパスにマッチする [...others].ts を作成し、API への汎用的なプロキシ処理を実装します。

pages/api/[...others].ts
import { createProxyMiddleware } from "http-proxy-middleware"
import { getIronSession } from "iron-session"
import { NextApiRequest, NextApiResponse } from "next"

import { SessionData, sessionOptions } from "@/sample-session"

export const config = {
  api: {
    bodyParser: false,
    externalResolver: true,
  },
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const session = await getIronSession<SessionData>(req, res, sessionOptions)

  // API にリクエストをプロキシ
  const proxy = createProxyMiddleware({
    target: "https://api.example.com",
    changeOrigin: true,
    pathRewrite: { "^/api": "" },
    // 追加のヘッダでアクセストークンを送信
    headers: { "Authorization": `Bearer ${session.accessToken}` },
  })

  proxy(req, res, (err) => {
    if (err) {
      res.status(500).json({ errors: ["接続に失敗しました。"] })
    }
  })
}

ここでは http-proxy-middleware を使ってプロキシしています。

App Router の場合

Route Handlers は Web 標準 API ベースのため http-proxy-middleware は使えないようです。
自前で fetch するコードを AI に提案してもらいましたが、実際に試したわけではないのをご了承ください。

app/api/[...others]/route.ts
import { cookies } from "next/headers"
import { getIronSession } from "iron-session"

import { SessionData, sessionOptions } from "@/sample-session"

export async function GET(request: Request) {
  return handleProxy(request)
}

export async function POST(request: Request) {
  return handleProxy(request)
}

export async function PUT(request: Request) {
  return handleProxy(request)
}

export async function DELETE(request: Request) {
  return handleProxy(request)
}

async function handleProxy(request: Request) {
  const session = await getIronSession<SessionData>(cookies(), sessionOptions)

  // URL を書き換え
  const url = new URL(request.url)
  const targetPath = url.pathname.replace(/^\/api/, "")
  const targetUrl = `https://api.example.com${targetPath}${url.search}`

  // API にリクエストをプロキシ
  const response = await fetch(targetUrl, {
    method: request.method,
    headers: {
      ...Object.fromEntries(request.headers),
      // 追加のヘッダでアクセストークンを送信
      "Authorization": `Bearer ${session.accessToken}`,
    },
    body: request.body,
  })
  return response
}

ブラウザ

上記の API Routes を呼び出します。

// BFF にリクエスト
const response = await fetch("/api/any-endpoint")

このリクエストでは Cookie ヘッダで sample-session Cookie が送信されます。
そして BFF がアクセストークン付きの API コールをしてくれたレスポンスが返ってきます。

SSR でも

SSR でもページを組み立てる情報を得るために API コールすると思います。
その実行箇所も BFF であり、getIronSession でアクセストークンを取り出せるので同様にリクエストに含めれば大丈夫です。

おわりに

以上、準備は少し大変ですがアクセストークンを安全に扱えるようになりました。
ブラウザが必ず BFF を経由することで、API の URL が知られなくなるメリットもあるかと思います。

採用情報

レンティオではエンジニアを募集しています。もし興味ありましたらこちらもご覧ください。

https://recruit.rentio.co.jp/engineer

Discussion