Validate Foundry session responses and test the authentication proxy
This commit is contained in:
@@ -6,4 +6,5 @@
|
||||
- 源数据只读;不复制整库,不从浏览器直连 SQLite 或持有数据库凭据。
|
||||
- 阅读与文章列表优先,无 KPI 仪表盘。默认 Vignelli;Editorial、Tufte 可切换。UI Craft Editorial 的衬线默认按用户现代简洁偏好及选定主题调整。
|
||||
- 项目级技能在 .agents/skills;beautiful-article 内含 Vignelli/Tufte authoring profiles,两者不是独立上游 skill。Beautiful Article 只在另行请求单篇 HTML 文章时进入其完整 harness。
|
||||
- 英文品牌已确定为 Thesisly,中文名已确定为研迹,远程地址待用户提供;Foundry project ID 预设 thesisly,但尚未注册;不得自行创建 Gitea 仓库。Git 身份、工作分支与人工 PR 合并沿用全局约定。
|
||||
- 英文品牌 Thesisly、中文名研迹;远程仓库为 `ssh://git@gitea.entroia.com:22222/Zayden-Jung/Thesisly.git`。用户已在 FOUNDRY_PROJECTS 新增 thesisly 回调,线上加载及登录仍待验收。main 仅允许本次已授权的空初始化提交,其余代码走工作分支与人工 PR 合并。
|
||||
- 论文库迁移至 PostgreSQL 由「调查云盘占用原因」任务负责;等待其迁移完成并核对实际 schema 后,在 Foundry Core 实现只读论文 API。不部署已撤回的 SQLite 方案,也不要求添加 FOUNDRY_RESEARCH_DATABASE。
|
||||
|
||||
@@ -44,7 +44,9 @@ pnpm build
|
||||
|
||||
## 当前验证边界
|
||||
|
||||
类型检查与会话策略单测已通过。生产构建、开发服务器、浏览器交互及真实OAuth尚未通过验收;此前构建/预览被自动审批阻止,随后用户要求暂不 build。前端目录为 `/Users/zzyang/thesisly`,远程已连接 Zayden-Jung/Thesisly,main 仅有用户授权的空初始化提交;后端项目注册待确认;论文 HTTP API 已纳入本目标,由代理在 Foundry Core 的 codex/research-api 分支实现。
|
||||
类型检查与13项测试已通过:除了Cookie/样本策略,测试直接调用生产认证代理,覆盖兑换、登录/邮件验证、续期、退出、错误、凭据隔离与请求限制。它们使用进程内模拟的Core响应,不代表真实OAuth成功,也不需要启动Next.js或编译。
|
||||
|
||||
生产构建、开发服务器、浏览器交互及真实OAuth尚未通过验收;此前构建/预览被自动审批阻止,随后用户要求暂不 build。前端目录为 `/Users/zzyang/thesisly`,远程已连接 Zayden-Jung/Thesisly,main 仅有用户授权的空初始化提交;论文 HTTP API 已纳入本目标,由代理在 Foundry Core 的 codex/research-api 分支实现。
|
||||
|
||||
## 当前后端依赖
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
|
||||
后端注册项目后,需用实际账户验证Google、Slack、首次授权、邮箱注册/验证、刷新恢复、跨标签/失效状态和退出。HTTP 302、单测或模拟token测试不等于真实OAuth授权成功。测试不要求用户把密码或OAuth token发给代理。
|
||||
|
||||
## 代理验收
|
||||
|
||||
App Router 的 route.ts 直接调用 src/lib/auth-gateway.mjs。tests/auth-gateway.test.mjs 在不编译/启动Next.js的情况下,用标准Request/Response执行同一代理:验证兑换只转发允许的请求头、token只存HttpOnly Cookie、登录成功必须有有效期限、邮件注册不要求立即签发会话、短期续期、撤销后清Cookie、异常时保留Cookie、401清理、请求体限制、同源校验和429重试时间。异常响应中的access_token/refresh_token同样从浏览器响应剥离。退出时若Core已判定会话失效,客户端直接清理已结束的登录状态。
|
||||
|
||||
## 2026-11-07 配置更新
|
||||
|
||||
用户已确认新增 Thesisly 的 FOUNDRY_PROJECTS 回调。FOUNDRY_RESEARCH_DATABASE 为已撤回的 SQLite 路径方案,无需添加。论文数据正在独立任务迁移到 PostgreSQL;本任务等待迁移后的数据库/schema与只读接入方式,再完成 Core 论文 API。
|
||||
|
||||
@@ -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); }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createAuthGateway } from "../src/lib/auth-gateway.mjs";
|
||||
import { COOKIE_NAME } from "../src/lib/auth-policy.mjs";
|
||||
const origin = "http://localhost:17322";
|
||||
const config = { project: "thesisly", appOrigin: origin, apiOrigin: "https://core.example.test" };
|
||||
function request(path, { method = "POST", headers = {}, body = "{}" } = {}) {
|
||||
return new Request(`${origin}/api/foundry/${path}`, { method, headers: { origin, "content-type": "application/json", ...headers }, ...(method === "GET" ? {} : { body }) });
|
||||
}
|
||||
|
||||
test("exchange keeps tokens in Cookie and forwards only approved headers", async () => {
|
||||
const gateway = createAuthGateway(config, async (url, options) => {
|
||||
assert.equal(url, "https://core.example.test/projects/thesisly/auth/exchange");
|
||||
assert.equal(options.headers.get("x-foundry-code"), "single-use-code");
|
||||
assert.equal(options.headers.get("x-foundry-verifier"), "tab-verifier");
|
||||
assert.equal(options.headers.get("authorization"), null);
|
||||
assert.equal(options.headers.get("cookie"), null);
|
||||
assert.equal(options.redirect, "error");
|
||||
assert.equal(options.cache, "no-store");
|
||||
return Response.json({ access_token: "session-token", refresh_token: "never-expose", expires_in: 90 });
|
||||
});
|
||||
const response = await gateway(request("auth/exchange", { headers: { "x-foundry-code": "single-use-code", "x-foundry-verifier": "tab-verifier", authorization: "Bearer injected", cookie: "other_app_session=private" } }), "auth/exchange");
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get("set-cookie"), /session-token; HttpOnly; SameSite=Lax; Path=\/; Max-Age=90$/);
|
||||
assert.deepEqual(await response.json(), { expires_in: 90 });
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
});
|
||||
|
||||
test("login and email verification require usable session responses", async () => {
|
||||
for (const path of ["auth/login", "auth/verify-email"]) {
|
||||
const valid = createAuthGateway({ ...config, appOrigin: "https://thesisly.example.test" }, async () => Response.json({ access_token: "token", expires_in: 90000000 }));
|
||||
const ok = await valid(request(path, { headers: { origin: "https://thesisly.example.test" } }), path);
|
||||
assert.equal(ok.status, 200);
|
||||
assert.match(ok.headers.get("set-cookie"), /Max-Age=2592000; Secure$/);
|
||||
for (const payload of [{ access_token: "token" }, { access_token: "token", expires_in: 0 }, { expires_in: 100 }, null]) {
|
||||
const response = await createAuthGateway(config, async () => Response.json(payload))(request(path), path);
|
||||
assert.equal(response.status, 502);
|
||||
assert.equal(response.headers.get("set-cookie"), null);
|
||||
assert.deepEqual(await response.json(), { error: "invalid_session_response" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("registration may request email verification without issuing a session", async () => {
|
||||
const response = await createAuthGateway(config, async () => Response.json({ status: "verification_required" }, { status: 202 }))(request("auth/register"), "auth/register");
|
||||
assert.equal(response.status, 202);
|
||||
assert.equal(response.headers.get("set-cookie"), null);
|
||||
});
|
||||
|
||||
test("restore uses only the site Cookie and honors the short renewal deadline", async () => {
|
||||
const gateway = createAuthGateway(config, async (_url, options) => {
|
||||
assert.equal(options.headers.get("authorization"), "Bearer stored-token");
|
||||
assert.equal(options.headers.get("cookie"), null);
|
||||
return Response.json({ id: "account-id", email: null }, { headers: { "X-Foundry-Session-Expires-In": "7" } });
|
||||
});
|
||||
const response = await gateway(request("auth/me", { method: "GET", headers: { cookie: `${COOKIE_NAME}=stored-token` } }), "auth/me");
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get("set-cookie"), /Max-Age=7$/);
|
||||
});
|
||||
|
||||
test("logout revokes before clearing; upstream failure preserves the local Cookie", async () => {
|
||||
const req = () => request("auth/logout", { headers: { cookie: `${COOKIE_NAME}=stored-token` } });
|
||||
const done = await createAuthGateway(config, async () => new Response(null, { status: 204 }))(req(), "auth/logout");
|
||||
assert.equal(done.status, 204);
|
||||
assert.match(done.headers.get("set-cookie"), /Max-Age=0$/);
|
||||
const failed = await createAuthGateway(config, async () => { throw new Error("offline"); })(req(), "auth/logout");
|
||||
assert.equal(failed.status, 502);
|
||||
assert.equal(failed.headers.get("set-cookie"), null);
|
||||
});
|
||||
|
||||
test("401 clears an expired session and strips unexpected refresh tokens", async () => {
|
||||
const gateway = createAuthGateway(config, async () => Response.json({ error: "invalid_session", refresh_token: "private" }, { status: 401 }));
|
||||
const response = await gateway(request("auth/me", { method: "GET", headers: { cookie: `${COOKIE_NAME}=expired` } }), "auth/me");
|
||||
assert.match(response.headers.get("set-cookie"), /Max-Age=0$/);
|
||||
assert.deepEqual(await response.json(), { error: "invalid_session" });
|
||||
});
|
||||
|
||||
test("invalid paths, methods, origins, cookies and bodies never reach Core", async () => {
|
||||
let calls = 0;
|
||||
const gateway = createAuthGateway(config, async () => { ++calls; throw new Error("must not call"); });
|
||||
const cases = [
|
||||
["canvas", request("canvas"), 404],
|
||||
["auth/login", request("auth/login", { method: "GET" }), 405],
|
||||
["auth/login", request("auth/login", { headers: { origin: "https://other.example.test" } }), 403],
|
||||
["auth/login", request("auth/login", { headers: { "sec-fetch-site": "cross-site" } }), 403],
|
||||
["auth/login", request("auth/login", { body: "not json" }), 400],
|
||||
["auth/login", request("auth/login", { body: JSON.stringify({ data: "x".repeat(16384) }) }), 413],
|
||||
["auth/me", request("auth/me", { method: "GET" }), 401],
|
||||
["auth/me", request("auth/me", { method: "GET", headers: { cookie: `${COOKIE_NAME}=%E0%A4%A` } }), 401],
|
||||
];
|
||||
for (const [path, req, expected] of cases) assert.equal((await gateway(req, path)).status, expected);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test("rate limits retain Retry-After without minting or exposing credentials", async () => {
|
||||
const gateway = createAuthGateway(config, async () => Response.json({ error: "rate_limited", access_token: "private", refresh_token: "private" }, { status: 429, headers: { "retry-after": "30" } }));
|
||||
const response = await gateway(request("auth/login"), "auth/login");
|
||||
assert.equal(response.status, 429);
|
||||
assert.equal(response.headers.get("retry-after"), "30");
|
||||
assert.equal(response.headers.get("set-cookie"), null);
|
||||
assert.deepEqual(await response.json(), { error: "rate_limited" });
|
||||
});
|
||||
Reference in New Issue
Block a user