Validate Foundry session responses and test the authentication proxy

This commit is contained in:
2026-11-07 01:42:22 +08:00
parent 9cb04ee69b
commit 3948cb33da
7 changed files with 234 additions and 42 deletions
+14 -37
View File
@@ -1,39 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { COOKIE_NAME, allowedAuthMethod, cookie, maxAge, sameOrigin } from "@/lib/auth-policy.mjs";
const appOrigin = process.env.APP_ORIGIN || "http://localhost:17322";
async function handler(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const path = (await params).path.join("/");
const headers = new Headers({ "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
const json = (body: unknown, status: number) => NextResponse.json(body, { status, headers });
if (!/^auth\/(me|login|register|verify-email|exchange|logout)$/.test(path)) return json({ error: "not_found" }, 404);
if (!allowedAuthMethod(path, req.method)) return json({ error: "method_not_allowed" }, 405);
if (req.method !== "GET" && (!sameOrigin(req.headers.get("origin") || "", appOrigin) || req.headers.get("sec-fetch-site") === "cross-site")) return json({ error: "invalid_origin" }, 403);
const token = req.cookies.get(COOKIE_NAME)?.value;
if ((path === "auth/me" || path === "auth/logout") && !token) return json({ error: "invalid_session" }, 401);
const project = process.env.FOUNDRY_PROJECT;
if (!project) return json({ error: "project_not_configured" }, 503);
let body: unknown;
if (req.method === "POST") {
const reader = req.body?.getReader(); let count = 0; const parts: Uint8Array[] = [];
if (reader) { try { for (;;) { const { value, done } = await reader.read(); if (done) break; count += value.byteLength; if (count > 16384) { await reader.cancel(); return json({ error: "body_too_large" }, 413); } parts.push(value); } } finally { reader.releaseLock(); } }
try { body = count ? JSON.parse(Buffer.concat(parts).toString("utf8")) : {}; } catch { return json({ error: "invalid_json" }, 400); }
}
const outbound: Record<string,string> = { "Content-Type": "application/json" };
if (token) outbound.Authorization = "Bearer " + token;
if (path === "auth/exchange") for (const h of ["x-foundry-code", "x-foundry-verifier"]) { const v = req.headers.get(h); if (v && v.length <= 256) outbound[h] = v; }
const secure = new URL(appOrigin).protocol === "https:";
try {
const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com";
const up = await fetch(`${origin}/projects/${encodeURIComponent(project)}/${path}`, { method: req.method, headers: outbound, body: req.method === "POST" ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(15000), cache: "no-store", redirect: "error" });
const retry = up.headers.get("retry-after"); if (retry) headers.set("Retry-After", retry);
const data = up.status === 204 ? null : await up.json();
if (data && typeof data === "object" && "access_token" in data) {
if (up.ok && typeof data.access_token === "string" && maxAge(data.expires_in) !== null) headers.set("Set-Cookie", cookie(data.access_token, data.expires_in, secure));
delete data.access_token; delete data.refresh_token;
} else if (token && up.ok && maxAge(up.headers.get("X-Foundry-Session-Expires-In")) !== null) headers.set("Set-Cookie", cookie(token, up.headers.get("X-Foundry-Session-Expires-In"), secure));
if (up.status === 401 || path === "auth/logout" && up.ok) headers.set("Set-Cookie", cookie("", 0, secure));
if (up.status === 204) return new NextResponse(null, { status: 204, headers });
return json(data, up.status);
} catch { return json({ error: "upstream_unavailable" }, 502); }
import { createAuthGateway } from "@/lib/auth-gateway.mjs";
const gateway = createAuthGateway({
project: process.env.FOUNDRY_PROJECT,
appOrigin: process.env.APP_ORIGIN,
apiOrigin: process.env.FOUNDRY_API_ORIGIN,
});
async function handler(
request: Request,
{ params }: { params: Promise<{ path: string[] }> },
) {
return gateway(request, (await params).path.join("/"));
}
export { handler as GET, handler as POST };
+10 -3
View File
@@ -32,12 +32,19 @@ export function Providers({ children }: { children: React.ReactNode }) {
return () => { active.current?.abort(); ++request.current; window.removeEventListener("focus", focus); window.removeEventListener("storage", change); };
}, [reload]);
async function logout() {
try {
await api("auth/logout", { method: "POST" });
const clear = () => {
++request.current; active.current?.abort(); setUser(null); setFailed(false); setLoading(false);
try { localStorage.setItem("thesisly-session-change", String(Date.now())); } catch {}
};
try {
await api("auth/logout", { method: "POST" });
clear();
toast.success("已退出登录");
} catch { toast.error("退出失败,请重试"); }
} catch (error) {
if (error instanceof Error && error.message === "invalid_session") {
clear(); toast.info("登录已结束");
} else toast.error("退出失败,请重试");
}
}
return <Session.Provider value={{ user, loading, failed, reload, logout }}>{children}<Toaster position="bottom-right" /></Session.Provider>;
}
+99
View File
@@ -0,0 +1,99 @@
import { COOKIE_NAME, allowedAuthMethod, cookie, maxAge, sameOrigin } from "./auth-policy.mjs";
const bodyLimit = 16 * 1024;
const authPath = /^auth\/(me|login|register|verify-email|exchange|logout)$/;
const sessionActions = new Set(["auth/login", "auth/exchange", "auth/verify-email"]);
function sessionToken(request) {
const entry = (request.headers.get("cookie") || "").split(";")
.map(value => value.trim()).find(value => value.startsWith(COOKIE_NAME + "="));
if (!entry) return "";
try {
const value = decodeURIComponent(entry.slice(COOKIE_NAME.length + 1));
return value.length <= 4096 && !/[\r\n]/.test(value) ? value : "";
} catch { return ""; }
}
async function requestBody(request) {
const reader = request.body?.getReader();
if (!reader) return {};
const parts = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > bodyLimit) { await reader.cancel(); throw new Error("body_too_large"); }
parts.push(value);
}
} finally { reader.releaseLock(); }
if (!size) return {};
const bytes = new Uint8Array(size);
let offset = 0;
for (const part of parts) { bytes.set(part, offset); offset += part.byteLength; }
return JSON.parse(new TextDecoder().decode(bytes));
}
// The production App Router proxy uses this function. Fetch injection lets its
// real request/response contract run under Node tests without compiling Next.js.
export function createAuthGateway(config, requestUpstream = fetch) {
const appOrigin = config.appOrigin || "http://localhost:17322";
const apiOrigin = config.apiOrigin || "https://api.foundry.entroia.com";
return async function gateway(request, path) {
const headers = new Headers({ "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
const json = (body, status) => {
const responseHeaders = new Headers(headers);
responseHeaders.set("Content-Type", "application/json");
return new Response(JSON.stringify(body), { status, headers: responseHeaders });
};
if (!authPath.test(path)) return json({ error: "not_found" }, 404);
if (!allowedAuthMethod(path, request.method)) {
headers.set("Allow", path === "auth/me" ? "GET" : "POST");
return json({ error: "method_not_allowed" }, 405);
}
if (request.method !== "GET" && (!sameOrigin(request.headers.get("origin") || "", appOrigin) || request.headers.get("sec-fetch-site") === "cross-site")) return json({ error: "invalid_origin" }, 403);
const token = sessionToken(request);
if ((path === "auth/me" || path === "auth/logout") && !token) return json({ error: "invalid_session" }, 401);
if (!config.project) return json({ error: "project_not_configured" }, 503);
let body;
if (request.method === "POST") {
try { body = await requestBody(request); }
catch (error) {
const tooLarge = error instanceof Error && error.message === "body_too_large";
return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400);
}
}
const outbound = new Headers({ "Content-Type": "application/json" });
if (token) outbound.set("Authorization", "Bearer " + token);
if (path === "auth/exchange") for (const name of ["x-foundry-code", "x-foundry-verifier"]) {
const value = request.headers.get(name);
if (value && value.length <= 256) outbound.set(name, value);
}
try {
const secure = new URL(appOrigin).protocol === "https:";
const upstream = await requestUpstream(`${apiOrigin}/projects/${encodeURIComponent(config.project)}/${path}`, {
method: request.method, headers: outbound,
body: request.method === "POST" ? JSON.stringify(body) : undefined,
signal: AbortSignal.any([AbortSignal.timeout(15000), request.signal]),
cache: "no-store", redirect: "error",
});
const retry = upstream.headers.get("retry-after");
if (retry) headers.set("Retry-After", retry);
const data = upstream.status === 204 ? null : await upstream.json();
const object = data !== null && typeof data === "object" && !Array.isArray(data);
const expires = object ? maxAge(data.expires_in) : null;
if (upstream.ok && sessionActions.has(path)) {
if (!object || typeof data.access_token !== "string" || !data.access_token || expires === null || expires === 0) return json({ error: "invalid_session_response" }, 502);
headers.set("Set-Cookie", cookie(data.access_token, expires, secure));
} else if (token && upstream.ok) {
const renewal = maxAge(upstream.headers.get("X-Foundry-Session-Expires-In"));
if (renewal !== null) headers.set("Set-Cookie", cookie(token, renewal, secure));
}
if (object) { delete data.access_token; delete data.refresh_token; }
if (upstream.status === 401 || (path === "auth/logout" && upstream.ok)) headers.set("Set-Cookie", cookie("", 0, secure));
if (upstream.status === 204) return new Response(null, { status: 204, headers });
return json(data, upstream.status);
} catch { return json({ error: "upstream_unavailable" }, 502); }
};
}