Validate Foundry session responses and test the authentication proxy
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user