#21: guard the web surfaces and connect them to the auth service

Three separate gaps, all of which made the F4 interface non-functional or
unprotected:

The admin console rendered for anyone. There was no middleware and no
guard in the layout, so /admin/dashboard returned 200 unauthenticated -
only its data calls failed. middleware.ts now redirects both surfaces to
their login when the session cookie is missing, expired, carries the wrong
audience, or is a pre-MFA challenge token. It reads the claims without
verifying the signature on purpose: the signing key belongs to the auth
service and must not reach the browser bundle. That is sound because this
is not the boundary - every /api/v1 request is verified for real upstream.

Nothing served /api/v1/*. next.config.ts had no rewrites and there were no
route handlers, so every call the pages made 404'd against the Next
server, while AUTH_SERVICE_URL sat unused. A catch-all route now proxies
to the auth service, forwarding the client address and preserving multiple
Set-Cookie headers, which logout needs to clear both surfaces. Session
cookies also move to Path=/, since a cookie pinned to /user or /admin was
never sent with the /api/v1 requests that needed it.

i18n existed only as files. The three bundles had perfect key parity and
real translations, but no library, no imports and no switcher: the UI was
hardcoded English and i18n.test.ts compared keys while the feature was
absent. There is now a provider, a hook, a language switcher, and
server-side Accept-Language negotiation that honours q-values and matches
a bare "pt" to pt-BR. Logins, both layouts and the settings screen consume
it; the admin pages still have English titles.

The settings screen invented app passwords in the browser with Math.random
and never stored them. It now calls the API, which returns the password
once and keeps only its hash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-02 09:33:49 -03:00
1 parent 833dc76c2e
commit 132cecd41c
16 files changed
+535 -43

No files matched your search

@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from "next/server";
/**
* Proxies /api/v1/* to the auth service.
*
* The browser cannot reach http://auth:3001 - that name only resolves inside
* the compose network - so every call the pages make used to 404 against the
* Next server. Routing them through here keeps the session cookie first-party
* (same origin, so SameSite=Strict still applies) and keeps the auth service
* off the public network.
*/
const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL ?? "http://auth:3001";
// Hop-by-hop headers must not be forwarded (RFC 9110 section 7.6.1), and the
// upstream sets its own content-length.
const STRIPPED = new Set([
"host",
"connection",
"keep-alive",
"transfer-encoding",
"upgrade",
"proxy-authorization",
"proxy-authenticate",
"te",
"trailer",
"content-length",
]);
function filterHeaders(source: Headers): Headers {
const out = new Headers();
source.forEach((value, key) => {
if (!STRIPPED.has(key.toLowerCase())) out.append(key, value);
});
return out;
}
async function forward(req: NextRequest, path: string[]): Promise<Response> {
const target = `${AUTH_SERVICE_URL}/api/v1/${path.join("/")}${req.nextUrl.search}`;
const headers = filterHeaders(req.headers);
// The client's address is what the auth service records on sessions and
// recovery-code use; without this every login looks like it came from the
// web container.
const forwardedFor = req.headers.get("x-forwarded-for");
if (forwardedFor) headers.set("x-forwarded-for", forwardedFor);
const hasBody = req.method !== "GET" && req.method !== "HEAD";
let upstream: Response;
try {
upstream = await fetch(target, {
method: req.method,
headers,
body: hasBody ? await req.arrayBuffer() : undefined,
redirect: "manual",
cache: "no-store",
});
} catch {
// A dead upstream is a gateway problem, not a 404 - the difference decides
// whether an operator looks at the auth service or at this route.
return NextResponse.json(
{ error: "UPSTREAM_UNAVAILABLE", message: "Authentication service is unreachable" },
{ status: 502 },
);
}
const responseHeaders = new Headers();
upstream.headers.forEach((value, key) => {
if (!STRIPPED.has(key.toLowerCase())) responseHeaders.append(key, value);
});
// Set-Cookie can legitimately appear more than once (logout clears both
// surfaces); getSetCookie preserves them where forEach would fold them into
// one malformed header.
responseHeaders.delete("set-cookie");
for (const cookie of upstream.headers.getSetCookie?.() ?? []) {
responseHeaders.append("set-cookie", cookie);
}
return new NextResponse(upstream.body, {
status: upstream.status,
headers: responseHeaders,
});
}
type Context = { params: Promise<{ path: string[] }> };
async function handler(req: NextRequest, ctx: Context): Promise<Response> {
const { path } = await ctx.params;
return forward(req, path ?? []);
}
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const DELETE = handler;
// Sessions are per-request; nothing here may be cached or prerendered.
export const dynamic = "force-dynamic";