/**
* 全ルートの横断処理(Next.js 16 の `proxy.ts` 規約)。
*
* ★このファイルは `middleware.ts` ではありません★
* Next.js 16 で `middleware` は非推奨になり `proxy` に改名されました
* (`node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md`)。
* `middleware.ts` を置くと dev サーバが deprecation 警告を出します。
*
* ★ここには順序制御だけを書き、ロジックを置かないこと★
* 1ファイルしか置けない規約なので、中身を膨らませると次に横断処理を足すときに壊れます。
* 公式ガイドの推奨どおり機能ごとに `lib/proxy/` へ分けて import しています。
*
* ★実行順(逆にしないこと)★
* 1. 相関IDの発行 … 弾いたリクエストのログにも ID が付くように★必ず先★
* (`docs/logging-design.md` §9 の申し送り)
* 2. IP制限 … 拒否なら 403
* 3. 認証 … 未認証なら 画面は /login へリダイレクト、APIは 401
* (★`/api/ds-agent/` 配下は cookie ではなく制御盤の Bearer で判定する(`authenticateAgent()`)。指示書AY D5・D22★)
*
* ★`runtime` を指定しないこと★ — proxy は Node.js ランタイム固定で、
* `runtime` を設定するとエラーになります(Edge は proxy では非対応)。
*/
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { clientIp } from "@/lib/auth/client-ip";
import { TRUSTED_PROXY_HOPS } from "@/lib/config/allowed-ips";
import { logEvent, runWithRequestId } from "@/lib/logger";
import {
authenticate,
authenticateAgent,
isAgentPath,
isApiPath,
SESSION_COOKIE_NAME,
} from "@/lib/proxy/authenticate";
import {
issueRequestHeaders,
REQUEST_ID_HEADER,
} from "@/lib/proxy/request-id";
export async function proxy(request: NextRequest) {
// 1. 相関IDの発行(★認証より先★)。クライアント指定の値は信頼せず常に上書き
const { requestId, headers } = issueRequestHeaders(request.headers);
// ★発行した ID をこの中の logEvent() にも効かせる★
// こうしないと、弾いたリクエストのログだけ request_id が "-" になり、
// 「認証失敗もログで追跡できるようにする」という順序の意図(logging-design.md §9)が
// 半分しか達成できない
return runWithRequestId(requestId, async () => {
const { pathname } = request.nextUrl;
const forwardedFor = request.headers.get("x-forwarded-for");
const agentPath = isAgentPath(pathname);
const authorization = agentPath ? request.headers.get("authorization") : null;
const decision = agentPath
? await authenticateAgent(pathname, forwardedFor, authorization)
: authenticate(
pathname,
forwardedFor,
request.cookies.get(SESSION_COOKIE_NAME)?.value ?? null
);
// 2. IP制限
if (decision.kind === "deny_ip") {
// ★client_ip は拒否時のみ出す(D-101)★ — 表記ゆれを正規化しない設計上、
// 拒否された接続元の生の表記が分からないと ALLOWED_IPS に登録できない。
// ★X-Forwarded-For の生値は出さない★(先頭側はクライアントが自由に書ける)
logEvent("warn", {
event: "auth.ip_denied",
route: pathname,
method: request.method,
client_ip: clientIp(forwardedFor, TRUSTED_PROXY_HOPS),
xff_element_count: forwardedFor ? forwardedFor.split(",").length : 0,
});
return NextResponse.json(
{
error: {
code: "forbidden",
message: "このIPアドレスからは利用できません",
},
},
{ status: 403, headers: { [REQUEST_ID_HEADER]: requestId } }
);
}
// 3. 認証
// ★ここではログを出さない★ — 未認証は「まだログインしていない」だけで、
// 画面を開くたびに出るとノイズになる。追跡したいログイン試行は
// /api/auth/login 側で auth.login_success / auth.login_failed として記録する
if (decision.kind === "unauthenticated") {
if (agentPath) {
// ★制御盤の認証の失敗は残す★(画面と違いノイズにならず、トークンの設定漏れ・失効の切り分けに要る)。
// ★ヘッダの値は出さない★
logEvent("warn", {
event: "ds_agent.auth_failed",
route: pathname,
method: request.method,
reason: authorization ? "invalid" : "missing",
});
}
if (isApiPath(pathname)) {
return NextResponse.json(
{ error: { code: "unauthorized", message: "認証が必要です" } },
{ status: 401, headers: { [REQUEST_ID_HEADER]: requestId } }
);
}
// ★絶対URLを自前で組まないこと★ — LB が HTTPS を終端するため、
// アプリ側で組み立てると http:// になる。request.nextUrl 起点で組む
const loginUrl = new URL("/login", request.nextUrl);
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
// ★NextResponse.next({ request: { headers } }) で「後段へ渡す」リクエストヘッダになる★
// NextResponse.next({ headers }) はクライアントへ返すレスポンスヘッダなので別物
return NextResponse.next({ request: { headers } });
});
}
export const config = {
/**
* ★matcher が無いと静的ファイルにも全部かかる★(CSS / JS / 画像が読めなくなる)。
* 除外するもの: `_next/static` / `_next/image` / `favicon.ico` / `public/` 配下の svg。
*
* ★`/login` と `/api/auth/login` はここで除外しないこと★ —
* 関数の中(`isPublicPath()`)で分岐して通す。matcher で削ると保護漏れの発見が難しくなる。
* ★matcher に変数は使えない★(ビルド時に静的解析されるため)。
* 許可IPリストを matcher に書こうとしないこと。
* ★`_next/data` は除外しても必ず proxy が走る★(意図的な挙動。除外できないものとして扱う)。
*/
matcher: [
"/((?!_next/static|_next/image|favicon\\.ico|.*\\.svg$).*)",
],
};