From 7eb6d3bdbf14a8884d84ccd2e86b966f6ac0413a Mon Sep 17 00:00:00 2001 From: Santhi Prakash <38608178+santhiprakash@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:21:02 +0530 Subject: [PATCH] fix(auth): use loopback URL for MCP OAuth JWKS verification (#3297) * fix(auth): use loopback URL for MCP OAuth JWKS verification Fetch the JWKS endpoint over the internal loopback address instead of the public APP_URL, so token verification works under Docker port-mapping, reverse proxies, and other deployments where the public URL does not loop back to the Node process. Also log the specific MCP OAuth verification error instead of swallowing it with a bare catch. Fixes #3077 * fix(auth): normalize internal JWKS URL and throttle MCP OAuth warnings - Problem: default loopback JWKS URL used PORT in dev where the server listens on SERVER_PORT (3001), and trailing-slash overrides produced //api/auth/jwks; unthrottled warn logs could flood on bad bearer tokens. - Fix: resolveInternalBaseUrl trims/normalizes BETTER_AUTH_INTERNAL_URL, mirrors apps/server listen-port selection, and MCP OAuth warnings are throttled to once per minute. - Verification: pnpm exec biome check on changed files; pnpm typecheck. * fix(auth): declare BETTER_AUTH_INTERNAL_URL in turbo globalEnv - Problem: Turborepo strict env mode strips undeclared BETTER_AUTH_INTERNAL_URL under pnpm dev, so the JWKS override silently falls back to loopback. - Fix: add BETTER_AUTH_INTERNAL_URL to turbo.json globalEnv (required for any new env var per CLAUDE.md). - Verification: python3 JSON parse of turbo.json; confirmed var was absent from globalEnv before this change. --------- Co-authored-by: Amruth Pillai --- apps/server/src/mcp/auth.ts | 21 +++++++++++++++++++-- packages/auth/src/config.ts | 19 ++++++++++++++++++- turbo.json | 1 + 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/auth.ts b/apps/server/src/mcp/auth.ts index e75cbbb45..53dbef5de 100644 --- a/apps/server/src/mcp/auth.ts +++ b/apps/server/src/mcp/auth.ts @@ -1,5 +1,21 @@ import { auth, verifyOAuthToken } from "@reactive-resume/auth/config"; +const OAUTH_WARN_THROTTLE_MS = 60_000; +let lastOAuthWarnAt = 0; + +function warnOAuthThrottled(message: string, detail?: unknown): void { + const now = Date.now(); + if (now - lastOAuthWarnAt < OAUTH_WARN_THROTTLE_MS) return; + lastOAuthWarnAt = now; + + if (detail !== undefined) { + console.warn(message, detail); + return; + } + + console.warn(message); +} + export class AuthError extends Error { constructor() { super("Unauthorized"); @@ -13,8 +29,9 @@ export async function authenticateRequest(request: Request): Promise { try { const payload = await verifyOAuthToken(authHeader.slice(7)); if (payload?.sub) return; - } catch { - // Invalid or expired token; fall through to API key auth. + warnOAuthThrottled("[MCP] OAuth token verified but missing `sub` claim"); + } catch (error) { + warnOAuthThrottled("[MCP] OAuth token verification failed:", error); } } diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index d10569166..be0922ab7 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -28,6 +28,23 @@ import { getTrustedOrigins } from "./trusted-origins"; const authBaseUrl = env.APP_URL; const isRateLimitEnabled = process.env.NODE_ENV === "production" && !env.FLAG_DISABLE_API_RATE_LIMIT; +// JWKS must be reachable from inside the Node runtime. `authBaseUrl` is the +// publicly-visible URL — under Docker port-mapping or behind a reverse proxy +// it does not loop back to the app process. Override with `BETTER_AUTH_INTERNAL_URL` +// for split deployments or custom servers that bind to a port not exposed via `PORT`. +function resolveInternalBaseUrl(): string { + const configured = process.env.BETTER_AUTH_INTERNAL_URL?.trim(); + if (configured) { + return configured.replace(/\/+$/, ""); + } + + const port = process.env.NODE_ENV === "production" ? (process.env.PORT ?? "3000") : String(env.SERVER_PORT); + + return `http://127.0.0.1:${port}`; +} + +const internalBaseUrl = resolveInternalBaseUrl(); + const oauthAudienceBase = authBaseUrl.replace(/\/$/, ""); const OAUTH_AUDIENCES = [ oauthAudienceBase, @@ -38,7 +55,7 @@ const OAUTH_AUDIENCES = [ export function verifyOAuthToken(token: string): Promise { return verifyAccessToken(token, { - jwksUrl: `${authBaseUrl}/api/auth/jwks`, + jwksUrl: `${internalBaseUrl}/api/auth/jwks`, verifyOptions: { issuer: `${authBaseUrl}/api/auth`, audience: OAUTH_AUDIENCES, diff --git a/turbo.json b/turbo.json index 2f228bcea..cf1995e40 100644 --- a/turbo.json +++ b/turbo.json @@ -51,6 +51,7 @@ "DATABASE_URL", "AUTH_SECRET", "BETTER_AUTH_API_KEY", + "BETTER_AUTH_INTERNAL_URL", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GITHUB_CLIENT_ID",