./knowledge-base/lambda/edge-auth/index.js

/**
 * Lambda@Edge: /era-assist/* 認証ガード
 *
 * CloudFront viewer-request イベントで実行される。
 * ブラウザが持つ okta_access_token Cookie の JWT 有効期限を確認し、
 * - 有効: そのまま App Runner へ転送
 * - 無効/なし: `/?redirect_to=/era-assist/` へリダイレクト
 *   → React SPA が Okta 認証後、元の /era-assist/ に戻す
 *
 * セキュリティ上の注意:
 * - JWT の署名検証は行わず、有効期限(exp)のみチェックする軽量実装
 * - App Runner への直接アクセスは WAF (X-Origin-Secret ヘッダー) で別途防御済み
 * - このガードはUXのため(未認証ユーザーを Okta へ誘導する目的)
 */
"use strict";

/**
 * Cookie ヘッダーを解析して Map に変換する
 * @param {string} cookieHeader - "name=value; name2=value2" 形式の文字列
 * @returns {Record<string, string>}
 */
function parseCookies(cookieHeader) {
  if (!cookieHeader) return {};
  return Object.fromEntries(
    cookieHeader.split(";").map((c) => {
      const eq = c.indexOf("=");
      if (eq === -1) return [c.trim(), ""];
      return [c.slice(0, eq).trim(), c.slice(eq + 1).trim()];
    }),
  );
}

/**
 * JWT の payload を base64url デコードして expiry を確認する
 * @param {string} token - JWT 文字列
 * @returns {boolean} 有効期限内なら true
 */
function isTokenValid(token) {
  try {
    const parts = token.split(".");
    if (parts.length !== 3) return false;

    // base64url → base64 変換してデコード
    const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
    const padding = "=".repeat((4 - (base64.length % 4)) % 4);
    const payload = JSON.parse(
      Buffer.from(base64 + padding, "base64").toString("utf8"),
    );

    // exp クレームが存在し、現在時刻より後であることを確認
    if (!payload.exp) return false;
    return payload.exp > Math.floor(Date.now() / 1000);
  } catch {
    return false;
  }
}

exports.handler = async (event) => {
  const request = event.Records[0].cf.request;
  const headers = request.headers;

  // Cookie を取得・解析
  const cookieHeader = headers.cookie?.[0]?.value || "";
  const cookies = parseCookies(cookieHeader);
  const token = cookies["okta_access_token"];

  // トークンが有効なら App Runner へ転送(パスリライト付き)
  if (token && isTokenValid(token)) {
    // /era-assist/xxx → /xxx へリライト(CloudFront Function の代替)
    const uri = request.uri;
    if (uri.startsWith("/era-assist/")) {
      request.uri = uri.slice(11); // "/era-assist" の11文字を除去
    } else if (uri === "/era-assist") {
      request.uri = "/";
    }
    return request;
  }

  // 未認証: React SPA のトップへリダイレクト
  // redirect_to パラメータを付けることで、Okta 認証後に元の URL に戻る
  const originalPath =
    request.uri + (request.querystring ? "?" + request.querystring : "");
  const redirectTo = encodeURIComponent(originalPath || "/era-assist/");

  return {
    status: "302",
    statusDescription: "Found",
    headers: {
      location: [
        {
          key: "Location",
          value: `/?redirect_to=${redirectTo}`,
        },
      ],
      "cache-control": [
        {
          key: "Cache-Control",
          value: "no-cache, no-store",
        },
      ],
    },
  };
};