Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion .env.prod.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand All @@ -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);

Expand Down
24 changes: 24 additions & 0 deletions server/src/lib/request-logging.ts
Original file line number Diff line number Diff line change
@@ -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),
});
}
8 changes: 4 additions & 4 deletions server/src/routes/proxy/catch-all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
138 changes: 138 additions & 0 deletions server/src/routes/proxy/public-password-reset.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const headers: Record<string, string> = {
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<FastifyReply> {
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<void> {
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",
);
},
);
}
20 changes: 20 additions & 0 deletions server/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const ENV_KEYS = [
"COOKIE_SECURE",
"PUBLIC_ORIGIN",
"TRUST_PROXY",
"PASSWORD_RESET_REQUEST_TIMEOUT_MS",
] as const;

let savedEnv: Record<string, string | undefined>;
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions server/test/helpers/build-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading