/**
* 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" };
}