/**
* 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";
import { verifyAgentAuthorization } from "@/lib/ds-agent/tokens";
/** 発行する 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/");
}
/**
* 制御盤(ラズパイ)向けのパスかどうか(指示書AY 段階5。D5・D22)。
* ★このパスは Bearer だけで通し、cookie では通さない★。それ以外のパスは cookie だけで通し、Bearer を見ない(表4)。
* ★前方一致で `/api/ds-agents` を拾わないこと★(表4 A8)。
*/
export function isAgentPath(pathname: string): boolean {
return pathname === "/api/ds-agent" || pathname.startsWith("/api/ds-agent/");
}
interface IpDeps {
allowedIps?: readonly string[];
allowAllWhenEmpty?: boolean;
allowDirectAccess?: boolean;
trustedProxyHops?: number;
}
/** IP制限の判定。★`authenticate()` と `authenticateAgent()` で共有する(複製しない)★ */
function ipAllowed(forwardedFor: string | null, deps: IpDeps): boolean {
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 ip = clientIp(forwardedFor, trustedProxyHops);
return isAllowedIp(ip, allowedIps, allowAllWhenEmpty, allowDirectAccess);
}
/**
* 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: IpDeps & {
expectedToken?: string | undefined;
} = {}
): AuthDecision {
const expectedToken =
"expectedToken" in deps ? deps.expectedToken : readAuthToken();
// 1. IP制限。★/login も含め全パスにかける★
// ログイン画面だけ誰でも開けるのでは、IP制限をかけた意味が薄れる
if (!ipAllowed(forwardedFor, deps)) {
return { kind: "deny_ip" };
}
// 2. 認証。/login と /api/auth/login はここで通す(matcher では除外しない)
if (isPublicPath(pathname)) return { kind: "allow" };
// ★制御盤のパスは cookie では通さない★(proxy は `authenticateAgent()` を呼ぶので、ここは保険。表4 A2)
if (isAgentPath(pathname)) return { kind: "unauthenticated" };
// ★cookie の値も必ず定数時間比較を通す。ここで === を書かないこと★
if (!verifyToken(sessionCookieValue, expectedToken)) {
return { kind: "unauthenticated" };
}
return { kind: "allow" };
}
/**
* 制御盤のパス(`isAgentPath()`)の判定(指示書AY 段階5。D5・D22)。順序は ★IP制限(403)→ Bearer(401)★。
* ★IP で拒否したらトークン検証を呼ばない★(表4 A7)。★戻り値に serial を入れない★(後段へヘッダで渡さない。
* ルートは `requireAgent()` で再検証して serial を得る)。
*
* @param authorization `Authorization` ヘッダの生値
* @param deps IP の設定と `verifyAgentToken`(★テストのために引数化★。省略時は `verifyAgentAuthorization`)
*/
export async function authenticateAgent(
pathname: string,
forwardedFor: string | null,
authorization: string | null,
deps: IpDeps & {
verifyAgentToken?: (authorization: string | null) => Promise<string | null>;
} = {}
): Promise<AuthDecision> {
if (!ipAllowed(forwardedFor, deps)) return { kind: "deny_ip" };
const verify = deps.verifyAgentToken ?? verifyAgentAuthorization;
const serial = await verify(authorization);
return serial === null ? { kind: "unauthenticated" } : { kind: "allow" };
}