From 3948cb33da6be8128bbb3c5ae10d202d2a63aecc Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sat, 7 Nov 2026 01:42:22 +0800 Subject: [PATCH] Validate Foundry session responses and test the authentication proxy --- AGENTS.md | 3 +- README.md | 4 +- docs/auth-integration.md | 4 + src/app/api/foundry/[...path]/route.ts | 51 ++++--------- src/components/providers.tsx | 13 +++- src/lib/auth-gateway.mjs | 99 ++++++++++++++++++++++++ tests/auth-gateway.test.mjs | 102 +++++++++++++++++++++++++ 7 files changed, 234 insertions(+), 42 deletions(-) create mode 100644 src/lib/auth-gateway.mjs create mode 100644 tests/auth-gateway.test.mjs diff --git a/AGENTS.md b/AGENTS.md index a919761..671bd78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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。 diff --git a/README.md b/README.md index e0bbf0b..8292fb3 100644 --- a/README.md +++ b/README.md @@ -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 分支实现。 ## 当前后端依赖 diff --git a/docs/auth-integration.md b/docs/auth-integration.md index 427a4d8..69259a2 100644 --- a/docs/auth-integration.md +++ b/docs/auth-integration.md @@ -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。 diff --git a/src/app/api/foundry/[...path]/route.ts b/src/app/api/foundry/[...path]/route.ts index 6f4ba61..e98023c 100644 --- a/src/app/api/foundry/[...path]/route.ts +++ b/src/app/api/foundry/[...path]/route.ts @@ -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 = { "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 }; diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 9860e77..811fe6c 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -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 {children}; } diff --git a/src/lib/auth-gateway.mjs b/src/lib/auth-gateway.mjs new file mode 100644 index 0000000..5b90034 --- /dev/null +++ b/src/lib/auth-gateway.mjs @@ -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); } + }; +} diff --git a/tests/auth-gateway.test.mjs b/tests/auth-gateway.test.mjs new file mode 100644 index 0000000..ea2a7e6 --- /dev/null +++ b/tests/auth-gateway.test.mjs @@ -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" }); +});