diff --git a/.env.example b/.env.example index 4134f0d..63a08b5 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,10 @@ CONTEXTFORGE_URL=http://0.0.0.0:8000 # Must match mcpgateway's own AUTH_HEADER_NAME. CONTEXTFORGE_AUTH_HEADER_NAME=Authorization +# Must exceed ContextForge's SMTP_TIMEOUT_SECONDS because password-reset +# handlers wait for email delivery before responding. +PASSWORD_RESET_REQUEST_TIMEOUT_MS=30000 + # Left UNSET on purpose — behaves correctly either way this file is used: # Native: config.ts's own default applies (memory:// — in-process, # lost on restart, single-instance only, no Redis needed). @@ -48,7 +52,8 @@ COOKIE_SECURE=false TRUST_PROXY=false # Exact scheme://host the BFF is publicly reached at (e.g. -# https://app.example.com), used for Origin-header validation on login/SSE. +# https://app.example.com), used for Origin-header validation on login, +# password recovery, and SSE. # Leave unset to derive it from the request itself — fine for a # single-hostname deployment; set explicitly behind a reverse proxy where # that derivation isn't trustworthy (e.g. TLS-terminated without diff --git a/.env.prod.example b/.env.prod.example index a63cf08..5487876 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -18,6 +18,10 @@ CONTEXTFORGE_URL=http://0.0.0.0:4444 # Must match mcpgateway's own AUTH_HEADER_NAME. CONTEXTFORGE_AUTH_HEADER_NAME=Authorization +# Must exceed ContextForge's SMTP_TIMEOUT_SECONDS because password-reset +# handlers wait for email delivery before responding. +PASSWORD_RESET_REQUEST_TIMEOUT_MS=30000 + # Real, persistent Redis — required. Set to your instance's address. # Docker, different network: use the host.docker.internal line instead. # REDIS_URL=redis://host.docker.internal:6379/0 @@ -44,7 +48,8 @@ TRUST_PROXY=false # Exact scheme://host this deployment is publicly reached at, e.g. # https://app.example.com. Required unless TRUST_PROXY=true — origin-guard.ts -# can't validate Origin behind a TLS-terminating proxy without one of these. +# can't validate login/password-recovery Origin behind a TLS-terminating +# proxy without one of these. PUBLIC_ORIGIN= # How often an open SSE connection re-checks Redis for session revocation, diff --git a/DOCKER.md b/DOCKER.md index 02b132d..d493fb9 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -71,11 +71,11 @@ this compose file's own `redis` service (`docker-compose.yml` defaults Full reference: `.env.example` (each var has an inline comment). Summary, grouped the same way: -| Group | Vars | Notes | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Works out of the box | `COOKIE_SECURE=false` | Required (or set `PUBLIC_ORIGIN`/`TRUST_PROXY`) for a zero-config boot — `server/src/config.ts` fails closed otherwise. | -| Must be set | `CONTEXTFORGE_URL` | No safe default reaches your gateway from inside the container. **No boot-time check catches a missing/wrong value** — it just fails every `/api/*` call at request time. Top thing to check if API calls all connection-refuse. | -| Fine as-is for dev | `PORT`, `HOST`, `CONTEXTFORGE_AUTH_HEADER_NAME`, `SESSION_TTL_SECONDS`, `REDIS_KEY_PREFIX`, `COOKIE_DOMAIN`, `TRUST_PROXY`, `PUBLIC_ORIGIN`, `SSE_SESSION_RECHECK_SECONDS`, `LOG_LEVEL` | Defaults match `server/src/config.ts`. | +| Group | Vars | Notes | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Works out of the box | `COOKIE_SECURE=false` | Required (or set `PUBLIC_ORIGIN`/`TRUST_PROXY`) for a zero-config boot — `server/src/config.ts` fails closed otherwise. | +| Must be set | `CONTEXTFORGE_URL` | No safe default reaches your gateway from inside the container. **No boot-time check catches a missing/wrong value** — it just fails every `/api/*` call at request time. Top thing to check if API calls all connection-refuse. | +| Fine as-is for dev | `PORT`, `HOST`, `CONTEXTFORGE_AUTH_HEADER_NAME`, `PASSWORD_RESET_REQUEST_TIMEOUT_MS`, `SESSION_TTL_SECONDS`, `REDIS_KEY_PREFIX`, `COOKIE_DOMAIN`, `TRUST_PROXY`, `PUBLIC_ORIGIN`, `SSE_SESSION_RECHECK_SECONDS`, `LOG_LEVEL` | Defaults match `server/src/config.ts`. | The image itself (`Dockerfile`) sets **none** of these — it ships respecting `config.ts`'s own defaults untouched. All configuration comes diff --git a/server/src/config.ts b/server/src/config.ts index 4bc102d..5439f67 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -33,6 +33,10 @@ export const config = { // Header mcpgateway reads the bearer token from — must match its own AUTH_HEADER_NAME. contextforgeAuthHeaderName: optional("CONTEXTFORGE_AUTH_HEADER_NAME", "Authorization"), + // Password-reset handlers can synchronously wait for SMTP (15s upstream + // default), so this must stay above the upstream email-delivery timeout. + passwordResetRequestTimeoutMs: Number(optional("PASSWORD_RESET_REQUEST_TIMEOUT_MS", "30000")), + // memory:// (default) = in-process store, no Redis needed — dev only. // See lib/memory-redis.ts. Use a real redis:// URL beyond a single // local dev process. optionalUnset so REDIS_URL="" also falls through @@ -93,6 +97,13 @@ if (!HTTP_TOKEN_RE.test(config.contextforgeAuthHeaderName)) { ); } +if ( + !Number.isSafeInteger(config.passwordResetRequestTimeoutMs) || + config.passwordResetRequestTimeoutMs <= 0 +) { + throw new Error("PASSWORD_RESET_REQUEST_TIMEOUT_MS must be a positive integer"); +} + // COOKIE_SECURE=true (prod default) with neither PUBLIC_ORIGIN nor TRUST_PROXY // set means origin-guard.ts derives its expected origin from request.protocol, // which is wrong behind a TLS-terminating proxy (it reads "http" while the diff --git a/server/src/index.ts b/server/src/index.ts index 2147991..6ec7353 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -10,6 +10,7 @@ import Fastify from "fastify"; import { config } from "./config.js"; +import { createRequestLogController } from "./lib/request-logging.js"; import cookiePlugin from "./plugins/cookie.js"; import csrfPlugin from "./plugins/csrf.js"; import redisPlugin from "./plugins/redis.js"; @@ -21,11 +22,16 @@ import loginRoute from "./routes/auth/login.js"; import logoutRoute from "./routes/auth/logout.js"; import sessionRoute from "./routes/auth/session.js"; import catchAllProxyRoute from "./routes/proxy/catch-all.js"; +import publicPasswordResetRoute from "./routes/proxy/public-password-reset.js"; import { startRevocationSubscriber } from "./routes/sse/revocation-subscriber.js"; import sseRoutes from "./routes/sse/routes.js"; import { sseUpstreamPool } from "./lib/upstream-http-client.js"; -const fastify = Fastify({ logger: { level: config.logLevel }, trustProxy: config.trustProxy }); +const fastify = Fastify({ + logger: { level: config.logLevel }, + logController: createRequestLogController(), + trustProxy: config.trustProxy, +}); await fastify.register(cookiePlugin); await fastify.register(redisPlugin); @@ -40,6 +46,7 @@ await fastify.register(logoutRoute); await fastify.register(sessionRoute); await fastify.register(changePasswordRequiredRoute); await fastify.register(sseRoutes); +await fastify.register(publicPasswordResetRoute); await fastify.register(catchAllProxyRoute); await fastify.register(appRoute); diff --git a/server/src/lib/request-logging.ts b/server/src/lib/request-logging.ts new file mode 100644 index 0000000..09cc6ae --- /dev/null +++ b/server/src/lib/request-logging.ts @@ -0,0 +1,24 @@ +// Location: ./client/server/src/lib/request-logging.ts +// Copyright contributors to the MCP-CONTEXT-FORGE project +// SPDX-License-Identifier: Apache-2.0 +// +// Password-reset tokens are bearer credentials. They appear in both the SPA +// route and the BFF API route, so Fastify's automatic request logger must not +// serialize either URL into application logs. + +import { LogController } from "fastify"; + +const SENSITIVE_PASSWORD_RESET_PREFIXES = [ + "/app/reset-password/", + "/api/auth/email/reset-password/", +] as const; + +export function isSensitivePasswordResetUrl(url: string): boolean { + return SENSITIVE_PASSWORD_RESET_PREFIXES.some((prefix) => url.startsWith(prefix)); +} + +export function createRequestLogController(): LogController { + return new LogController({ + disableRequestLogging: (request) => isSensitivePasswordResetUrl(request.url), + }); +} diff --git a/server/src/routes/proxy/catch-all.ts b/server/src/routes/proxy/catch-all.ts index 8d71943..0cc0312 100644 --- a/server/src/routes/proxy/catch-all.ts +++ b/server/src/routes/proxy/catch-all.ts @@ -4,10 +4,10 @@ // // Generic `/api/*` -> FastAPI proxy. Covers the bulk of the API surface // without mirroring routes: session lookup -> inject Authorization header -> -// forward via @fastify/reply-from. Only BFF-owned auth routes and SSE routes -// (registered separately, see routes/sse/) are excluded — find-my-way -// resolves their static paths before this wildcard regardless of -// registration order, so there's no risk of this route swallowing them. +// forward via @fastify/reply-from. BFF-owned auth routes, narrowly allowlisted +// public password-reset routes, and SSE routes are registered separately; +// find-my-way resolves their static/parameterized paths before this wildcard +// regardless of registration order, so this route cannot swallow them. // // SAFE_METHODS mirrors mcpgateway/middleware/csrf_middleware.py so the // browser<->BFF CSRF boundary matches the same-origin behavior it replaces. diff --git a/server/src/routes/proxy/public-password-reset.ts b/server/src/routes/proxy/public-password-reset.ts new file mode 100644 index 0000000..adcab63 --- /dev/null +++ b/server/src/routes/proxy/public-password-reset.ts @@ -0,0 +1,138 @@ +// Location: ./client/server/src/routes/proxy/public-password-reset.ts +// Copyright contributors to the MCP-CONTEXT-FORGE project +// SPDX-License-Identifier: Apache-2.0 +// +// Narrow unauthenticated proxy for ContextForge's password-recovery API. +// These routes cannot use the protected /api/* catch-all: requesting a reset +// link and validating/completing a reset token must work before login. +// +// Keep this allowlist explicit. A generic unauthenticated /api/auth/* proxy +// would expose protected email-auth administration routes without the BFF's +// session and bearer-token boundary. + +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; + +import { config } from "../../config.js"; +import { setNoStore } from "../../lib/no-store.js"; +import { isForbiddenCrossOrigin } from "../../lib/origin-guard.js"; + +const UPSTREAM_PREFIX = "/auth/email"; + +type PasswordResetOperation = "request" | "validate" | "complete"; + +interface ResetTokenParams { + token: string; +} + +function upstreamHeaders(request: FastifyRequest): Record { + const headers: Record = { + accept: "application/json", + "content-type": "application/json", + // Preserve real client data for upstream rate limiting and audit events. + "x-forwarded-for": request.ip, + "x-real-ip": request.ip, + }; + + const userAgent = request.headers["user-agent"]; + if (typeof userAgent === "string") headers["user-agent"] = userAgent; + + return headers; +} + +async function forwardPublicRequest( + request: FastifyRequest, + reply: FastifyReply, + upstreamPath: string, + operation: PasswordResetOperation, +): Promise { + setNoStore(reply); + + let upstreamResponse: Response; + let responseBody: string; + try { + upstreamResponse = await fetch(`${config.contextforgeUrl}${upstreamPath}`, { + method: request.method, + headers: upstreamHeaders(request), + body: request.method === "GET" ? undefined : JSON.stringify(request.body), + signal: AbortSignal.timeout(config.passwordResetRequestTimeoutMs), + }); + responseBody = await upstreamResponse.text(); + } catch (err) { + request.log.error( + { errorType: err instanceof Error ? err.name : typeof err, operation }, + "upstream password-reset request failed", + ); + return reply.code(502).send({ error: "upstream_unavailable" }); + } + + // Forward only response metadata needed by the SPA. In particular, never + // pass upstream Set-Cookie or Location headers through this public route. + const contentType = upstreamResponse.headers.get("content-type"); + if (contentType) reply.header("content-type", contentType); + const retryAfter = upstreamResponse.headers.get("retry-after"); + if (retryAfter) reply.header("retry-after", retryAfter); + + if (!responseBody) return reply.code(upstreamResponse.status).send(); + + if (contentType?.toLowerCase().includes("json")) { + try { + return reply.code(upstreamResponse.status).send(JSON.parse(responseBody)); + } catch (err) { + request.log.error( + { errorType: err instanceof Error ? err.name : typeof err, operation }, + "upstream password-reset returned invalid JSON", + ); + return reply.code(502).send({ error: "upstream_invalid_response" }); + } + } + + return reply.code(upstreamResponse.status).send(responseBody); +} + +function rejectCrossOriginMutation( + request: FastifyRequest, + reply: FastifyReply, +): FastifyReply | undefined { + if (!isForbiddenCrossOrigin(request)) return undefined; + + setNoStore(reply); + return reply.code(403).send({ error: "cross_site_request_forbidden" }); +} + +export default async function publicPasswordResetRoute(fastify: FastifyInstance): Promise { + fastify.post( + "/api/auth/email/forgot-password", + async (request: FastifyRequest, reply: FastifyReply) => { + const rejection = rejectCrossOriginMutation(request, reply); + if (rejection) return rejection; + + return forwardPublicRequest(request, reply, `${UPSTREAM_PREFIX}/forgot-password`, "request"); + }, + ); + + fastify.get<{ Params: ResetTokenParams }>( + "/api/auth/email/reset-password/:token", + async (request: FastifyRequest<{ Params: ResetTokenParams }>, reply: FastifyReply) => + forwardPublicRequest( + request, + reply, + `${UPSTREAM_PREFIX}/reset-password/${encodeURIComponent(request.params.token)}`, + "validate", + ), + ); + + fastify.post<{ Params: ResetTokenParams }>( + "/api/auth/email/reset-password/:token", + async (request: FastifyRequest<{ Params: ResetTokenParams }>, reply: FastifyReply) => { + const rejection = rejectCrossOriginMutation(request, reply); + if (rejection) return rejection; + + return forwardPublicRequest( + request, + reply, + `${UPSTREAM_PREFIX}/reset-password/${encodeURIComponent(request.params.token)}`, + "complete", + ); + }, + ); +} diff --git a/server/test/config.test.ts b/server/test/config.test.ts index ce55538..edc84e3 100644 --- a/server/test/config.test.ts +++ b/server/test/config.test.ts @@ -14,6 +14,7 @@ const ENV_KEYS = [ "COOKIE_SECURE", "PUBLIC_ORIGIN", "TRUST_PROXY", + "PASSWORD_RESET_REQUEST_TIMEOUT_MS", ] as const; let savedEnv: Record; @@ -90,6 +91,25 @@ describe("config validation", () => { await expect(run()).resolves.toBeTruthy(); resetModules(); }); + + it("defaults the password-reset timeout above the upstream SMTP timeout", async () => { + delete process.env.PASSWORD_RESET_REQUEST_TIMEOUT_MS; + + const { resetModules, run } = await freshImport(); + const loaded = (await run()) as { passwordResetRequestTimeoutMs: number }; + expect(loaded.passwordResetRequestTimeoutMs).toBe(30_000); + resetModules(); + }); + + it("rejects a non-positive password-reset timeout", async () => { + process.env.PASSWORD_RESET_REQUEST_TIMEOUT_MS = "0"; + + const { resetModules, run } = await freshImport(); + await expect(run()).rejects.toThrow( + "PASSWORD_RESET_REQUEST_TIMEOUT_MS must be a positive integer", + ); + resetModules(); + }); }); // vi.resetModules() alone doesn't help here because config.ts throws at diff --git a/server/test/helpers/build-app.ts b/server/test/helpers/build-app.ts index b943568..716d957 100644 --- a/server/test/helpers/build-app.ts +++ b/server/test/helpers/build-app.ts @@ -18,6 +18,7 @@ import loginRoute from "../../src/routes/auth/login.js"; import logoutRoute from "../../src/routes/auth/logout.js"; import sessionRoute from "../../src/routes/auth/session.js"; import catchAllProxyRoute from "../../src/routes/proxy/catch-all.js"; +import publicPasswordResetRoute from "../../src/routes/proxy/public-password-reset.js"; export class FakeRedis { private store = new Map(); @@ -60,6 +61,7 @@ export async function buildTestApp(opts: { withProxy?: boolean } = {}): Promise< await fastify.register(logoutRoute); await fastify.register(sessionRoute); await fastify.register(changePasswordRequiredRoute); + await fastify.register(publicPasswordResetRoute); if (opts.withProxy) { await fastify.register(catchAllProxyRoute); diff --git a/server/test/password-reset.test.ts b/server/test/password-reset.test.ts new file mode 100644 index 0000000..b459170 --- /dev/null +++ b/server/test/password-reset.test.ts @@ -0,0 +1,156 @@ +// Location: ./client/server/test/password-reset.test.ts +// Copyright contributors to the MCP-CONTEXT-FORGE project +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { config } from "../src/config.js"; +import { buildTestApp, type TestApp } from "./helpers/build-app.js"; + +interface UpstreamCall { + url: string; + init: RequestInit; +} + +function mockUpstream( + body: unknown, + options: { status?: number; headers?: Record } = {}, +): UpstreamCall[] { + const calls: UpstreamCall[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string | URL | Request, init: RequestInit = {}) => { + calls.push({ url: String(url), init }); + return new Response(JSON.stringify(body), { + status: options.status ?? 200, + headers: { "content-type": "application/json", ...options.headers }, + }); + }), + ); + return calls; +} + +describe("public password-reset proxy", () => { + let app: TestApp; + + beforeEach(async () => { + app = await buildTestApp({ withProxy: true }); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + await app.fastify.close(); + }); + + it("lets an anonymous visitor request a reset link without forwarding browser secrets", async () => { + const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); + const calls = mockUpstream({ + success: true, + message: "If this email is registered, you will receive a reset link.", + }); + + const response = await app.fastify.inject({ + method: "POST", + url: "/api/auth/email/forgot-password", + headers: { + host: "app.example.test", + origin: "http://app.example.test", + "user-agent": "reset-flow-test-agent", + authorization: "Bearer browser-supplied-token", // pragma: allowlist secret + cookie: "next-auth.session-token=stale-browser-cookie", // pragma: allowlist secret + }, + payload: { email: "person@example.com" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().success).toBe(true); + expect(response.headers["cache-control"]).toBe("no-store, private"); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe(`${config.contextforgeUrl}/auth/email/forgot-password`); + expect(JSON.parse(String(calls[0]?.init.body))).toEqual({ email: "person@example.com" }); + + const headers = calls[0]?.init.headers as Record; + expect(headers.authorization).toBeUndefined(); + expect(headers.cookie).toBeUndefined(); + expect(headers["user-agent"]).toBe("reset-flow-test-agent"); + expect(headers["x-forwarded-for"]).toBeTruthy(); + expect(timeoutSpy).toHaveBeenCalledWith(config.passwordResetRequestTimeoutMs); + }); + + it("lets an anonymous visitor validate and complete a reset token", async () => { + const calls = mockUpstream({ valid: true, message: "Reset token is valid", expires_at: null }); + + const validation = await app.fastify.inject({ + method: "GET", + url: "/api/auth/email/reset-password/token%20with%20space", + }); + expect(validation.statusCode).toBe(200); + expect(validation.json().valid).toBe(true); + + const completion = await app.fastify.inject({ + method: "POST", + url: "/api/auth/email/reset-password/token%20with%20space", + payload: { new_password: "New-password1", confirm_password: "New-password1" }, + }); + expect(completion.statusCode).toBe(200); + + expect(calls.map((call) => call.url)).toEqual([ + `${config.contextforgeUrl}/auth/email/reset-password/token%20with%20space`, + `${config.contextforgeUrl}/auth/email/reset-password/token%20with%20space`, + ]); + expect(calls[0]?.init.method).toBe("GET"); + expect(calls[0]?.init.body).toBeUndefined(); + expect(calls[1]?.init.method).toBe("POST"); + expect(JSON.parse(String(calls[1]?.init.body))).toEqual({ + new_password: "New-password1", + confirm_password: "New-password1", + }); + }); + + it("rejects cross-origin password-reset mutations before calling upstream", async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + + const response = await app.fastify.inject({ + method: "POST", + url: "/api/auth/email/forgot-password", + headers: { host: "app.example.test", origin: "https://evil.example.test" }, + payload: { email: "person@example.com" }, + }); + + expect(response.statusCode).toBe(403); + expect(response.json()).toEqual({ error: "cross_site_request_forbidden" }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("passes through rate limits but not upstream cookies", async () => { + mockUpstream( + { detail: "Too many requests. Please try again later." }, + { + status: 429, + headers: { "retry-after": "30", "set-cookie": "upstream_session=secret; HttpOnly" }, // pragma: allowlist secret + }, + ); + + const response = await app.fastify.inject({ + method: "POST", + url: "/api/auth/email/forgot-password", + payload: { email: "person@example.com" }, + }); + + expect(response.statusCode).toBe(429); + expect(response.headers["retry-after"]).toBe("30"); + expect(response.headers["set-cookie"]).toBeUndefined(); + }); + + it("keeps every other email-auth API route behind session authentication", async () => { + const response = await app.fastify.inject({ + method: "GET", + url: "/api/auth/email/admin/users", + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ error: "unauthenticated" }); + }); +}); diff --git a/server/test/request-logging.test.ts b/server/test/request-logging.test.ts new file mode 100644 index 0000000..bcb12d5 --- /dev/null +++ b/server/test/request-logging.test.ts @@ -0,0 +1,68 @@ +// Location: ./client/server/test/request-logging.test.ts +// Copyright contributors to the MCP-CONTEXT-FORGE project +// SPDX-License-Identifier: Apache-2.0 + +import Fastify from "fastify"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createRequestLogController, + isSensitivePasswordResetUrl, +} from "../src/lib/request-logging.js"; +import publicPasswordResetRoute from "../src/routes/proxy/public-password-reset.js"; + +describe("password-reset request logging", () => { + afterEach(() => vi.unstubAllGlobals()); + + it.each([ + "/app/reset-password/plaintext-token", + "/app/reset-password/token%2Fwith%20space?source=email", + "/api/auth/email/reset-password/plaintext-token", + "/api/auth/email/reset-password/plaintext-token?check=true", + ])("marks token-bearing URL as sensitive: %s", (url) => { + expect(isSensitivePasswordResetUrl(url)).toBe(true); + }); + + it.each([ + "/app/forgot-password", + "/api/auth/email/forgot-password", + "/api/auth/email/admin/users", + ])("keeps non-token URL observable: %s", (url) => { + expect(isSensitivePasswordResetUrl(url)).toBe(false); + }); + + it("keeps reset tokens out of automatic and manual error logs", async () => { + const token = "plaintext-reset-token"; // pragma: allowlist secret + const logs: string[] = []; + const app = Fastify({ + logger: { + level: "info", + stream: { write: (message: string) => logs.push(message) }, + }, + logController: createRequestLogController(), + }); + await app.register(publicPasswordResetRoute); + await app.ready(); + + vi.stubGlobal( + "fetch", + vi.fn(async () => { + // Even if an upstream error embeds the URL/token, only its safe type + // is logged by the route. + throw new TypeError(`failed request containing ${token}`); + }), + ); + + const response = await app.inject({ + method: "GET", + url: `/api/auth/email/reset-password/${token}`, + }); + await app.close(); + + expect(response.statusCode).toBe(502); + const output = logs.join("\n"); + expect(output).toContain('"operation":"validate"'); + expect(output).toContain('"errorType":"TypeError"'); + expect(output).not.toContain(token); + }); +}); diff --git a/src/api/client.ts b/src/api/client.ts index 7e845b4..9c3cda4 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -110,7 +110,11 @@ interface RequestOptions { body?: unknown; /** Extra headers merged on top of the defaults. */ headers?: Record; - /** Pass `false` for public endpoints that do not require auth or CSRF (e.g. login). */ + /** + * Pass `false` for public endpoints handled by an unauthenticated BFF route. + * This suppresses the SPA's CSRF header and 401 redirect; it does not bypass + * authentication on the BFF's protected /api/* catch-all. + */ authenticated?: boolean; /** AbortSignal for request cancellation/timeout. */ signal?: AbortSignal;