Newer
Older
client_dwatch / lib / proxy / authenticate.ts
/**
 * IP制限と認証の判定(`proxy.ts` から★相関IDの発行の後に★呼ぶ)。
 *
 * ★ここは「判定するだけ」。Response は組み立てない★
 *   組み立てを `proxy.ts` 側に寄せることで、判定を素の値でテストでき、
 *   `proxy.ts` は薄い順序制御だけで済む。
 *
 * 判定の順序は ★IP制限(403)→ 認証(401 / redirect)★。
 * IPで弾くべき相手にトークンの当たり外れを試させないため、IPを先に見る。
 */
import { clientIp, isAllowedIp } from "@/lib/auth/client-ip";
import { SESSION_COOKIE_NAME } from "@/lib/auth/cookie";
import { readAuthToken, verifyToken } from "@/lib/auth/token";
import {
  ALLOW_ALL_WHEN_EMPTY,
  ALLOW_DIRECT_ACCESS,
  ALLOWED_IPS,
  TRUSTED_PROXY_HOPS,
} from "@/lib/config/allowed-ips";

/** 発行する cookie の名前。定義は `lib/auth/cookie.ts`(発行・削除・判定でズレないように1箇所) */
export { SESSION_COOKIE_NAME };

/** 認証をかけないパス。★matcher で除外せず、ここで分岐して通す★(保護漏れを見つけやすくするため) */
const PUBLIC_PATHS = ["/login", "/api/auth/login"] as const;

export type AuthDecision =
  /** 通してよい */
  | { kind: "allow" }
  /** IP制限で拒否(403) */
  | { kind: "deny_ip" }
  /** 未認証(画面は /login へリダイレクト、APIは 401) */
  | { kind: "unauthenticated" };

/** `/login` と `/api/auth/login` は認証不要(ここを保護すると誰もログインできない) */
export function isPublicPath(pathname: string): boolean {
  return PUBLIC_PATHS.some(
    (p) => pathname === p || pathname.startsWith(`${p}/`)
  );
}

/**
 * APIルート向けかどうか。未認証時に 401 を返すか `/login` へ飛ばすかの分岐に使う。
 * ブラウザのページ遷移をJSONの401で返すと画面が真っ白になるため分ける。
 */
export function isApiPath(pathname: string): boolean {
  return pathname === "/api" || pathname.startsWith("/api/");
}

/**
 * IP制限と認証を判定する。
 *
 * @param pathname `request.nextUrl.pathname`
 * @param forwardedFor `X-Forwarded-For` ヘッダの生値
 * @param sessionCookieValue `dwatch_session` cookie の値
 * @param deps 設定値と期待トークン。★テストのために引数化してある★
 *   (省略時は `lib/config/allowed-ips.ts` と `AUTH_TOKEN` を読む)
 */
export function authenticate(
  pathname: string,
  forwardedFor: string | null,
  sessionCookieValue: string | null,
  deps: {
    allowedIps?: readonly string[];
    allowAllWhenEmpty?: boolean;
    allowDirectAccess?: boolean;
    trustedProxyHops?: number;
    expectedToken?: string | undefined;
  } = {}
): AuthDecision {
  const allowedIps = deps.allowedIps ?? ALLOWED_IPS;
  const allowAllWhenEmpty = deps.allowAllWhenEmpty ?? ALLOW_ALL_WHEN_EMPTY;
  const allowDirectAccess = deps.allowDirectAccess ?? ALLOW_DIRECT_ACCESS;
  const trustedProxyHops = deps.trustedProxyHops ?? TRUSTED_PROXY_HOPS;
  const expectedToken =
    "expectedToken" in deps ? deps.expectedToken : readAuthToken();

  // 1. IP制限。★/login も含め全パスにかける★
  //    ログイン画面だけ誰でも開けるのでは、IP制限をかけた意味が薄れる
  const ip = clientIp(forwardedFor, trustedProxyHops);
  if (!isAllowedIp(ip, allowedIps, allowAllWhenEmpty, allowDirectAccess)) {
    return { kind: "deny_ip" };
  }

  // 2. 認証。/login と /api/auth/login はここで通す(matcher では除外しない)
  if (isPublicPath(pathname)) return { kind: "allow" };

  // ★cookie の値も必ず定数時間比較を通す。ここで === を書かないこと★
  if (!verifyToken(sessionCookieValue, expectedToken)) {
    return { kind: "unauthenticated" };
  }

  return { kind: "allow" };
}