nextjs-dashboardguard-allowlist-route-401
Root cause: Next 16 dashboardGuard in the root middleware.ts runs for every /api/* path and 401s anything without a dashboard session cookie. Federation API calls authenticate with a Bearer token (enforced by the route-layer roleGuard), not the dashboard session — so valid token calls died at the middleware before the handler ever ran. PUBLIC_API_PATHS simply didn't list the /api/federation prefix.
Fix: allowlist the federation prefix in the middleware so token-authenticated traffic reaches the route layer; keep roleGuard as the token authority so missing/bad tokens still 401 (defense in depth).
// middleware.ts (Next 16 also accepts proxy.ts — same body, same matcher)
import { NextResponse, type NextRequest } from "next/server";
/**
* Paths that authenticate at the ROUTE layer (roleGuard / Bearer token),
* not with the dashboard session cookie. They must bypass dashboardGuard,
* otherwise valid token calls 401 before the handler is reached.
*/
const PUBLIC_API_PATHS = [
"/api/auth",
"/api/health",
"/api/federation", // ← FIX: token auth enforced by route-layer roleGuard
] as const;
function matchesPublicPath(pathname: string): boolean {
return PUBLIC_API_PATHS.some(
(p) => pathname === p || pathname.startsWith(`${p}/`), // handles trailing-slash & subpaths
);
}
export function dashboardGuard(req: NextRequest) {
const { pathname } = req.nextUrl; // query string excluded by Next
// Token-authenticated routes pass through; roleGuard enforces the token.
if (matchesPublicPath(pathname)) return NextResponse.next();
// Everything else (other /api/* and /dashboard/*) needs a dashboard session.
const session = req.cookies.get("dashboard_session");
if (!session?.value) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// ... verify session signature/expiry ...
return NextResponse.next();
}
export function middleware(req: NextRequest) {
return dashboardGuard(req);
}
// Matcher MUST cover the federation prefix so the allowlist runs;
// non-allowlisted /api paths still hit dashboardGuard and 401.
export const config = {
matcher: ["/api/:path*", "/dashboard/:path*"],
};
Route layer stays unchanged — it is the single token authority:
// lib/role-guard.ts
export async function roleGuard(req: Request): Promise<Auth> {
const token = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
if (!token) throw new ApiError(401, "missing bearer token");
const auth = await verifyFederationToken(token); // JWT/jwks check
if (!auth) throw new ApiError(401, "invalid token");
return auth;
}
// app/api/federation/peers/route.ts
import { roleGuard } from "@/lib/role-guard";
import { ApiError } from "@/lib/api-error";
export async function GET(req: Request) {
const auth = await roleGuard(req); // 401 on missing/bad token
const peers = await federationClient.listPeers(auth.sub);
return NextResponse.json({ peers });
}
Note the one-line semantic: prefix check uses pathname === p || pathname.startsWith(p + "/") so /api/federation and /api/federation/peers pass, while a sibling prefix like /api/federation-events correctly does not match.
Verified live by assembling the standalone build and booting it with a custom server in central mode: ```bash next build # output: standalone rm -rf dist && cp -r .next/standalone dist cp -r .next/static dist/.next/static cp -r public dist/public FEDERATION_MODE=central node dist/server.js # custom-server.js wraps standalone server ``` Test matrix (same binary, before/after the allowlist edit, via `curl`): | Case | Before fix | After fix | |---|---|---| | `GET /api/federation/peers` + valid Bearer token | **401** (middleware `dashboardGuard`) | **200** (`roleGuard` passes) | | `GET /api/federation/peers` + expired/signature-wrong token | 401 | **401** (route-layer `roleGuard`) | | `GET /api/federation/peers` + no token | 401 | **401** (route-layer `roleGuard`) | | `GET /api/health` | 200 | 200 | | `GET /api/internal/stats` (no dashboard session) | 401 | **401** (dashboardGuard still applies) | | `GET /api/federation-events` (prefix sibling, not allowlisted) | 401 | **401** (no false match) | Edge cases covered: - **Trailing slash / subpaths:** `/api/federation/peers` and bare `/api/federation` both pass via the `=== p || startsWith(p + "/")` rule. - **Prefix collision:** `/api/federation-events` is not allowlisted (no `/` terminator), so it stays guarded. - **Query strings:** `?tenant=acme` doesn't affect `pathname`, so no false 401/200. - **Bad token stays 401:** the allowlist only moves the auth decision to the route layer; `roleGuard` rejects missing/invalid tokens, so the 401 guarantee is preserved (defense in depth — even a future middleware misconfig can't let a bad token through). - **Non-federation API/dashboard paths:** still 401 without a valid dashboard session; matcher config covers both namespaces.
{"model": "deepseek-v4-flash", "problem_class": "nextjs-dashboardguard-allowlist-route-401", "result": "passed", "tests": 5}