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 <im.amruth@gmail.com>
This commit is contained in:
Santhi Prakash
2026-08-13 22:51:02 +02:00
committed by GitHub
co-authored by Amruth Pillai
parent 5fc9c3ee04
commit 7eb6d3bdbf
3 changed files with 38 additions and 3 deletions
+19 -2
View File
@@ -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<void> {
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);
}
}
+18 -1
View File
@@ -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<JWTPayload> {
return verifyAccessToken(token, {
jwksUrl: `${authBaseUrl}/api/auth/jwks`,
jwksUrl: `${internalBaseUrl}/api/auth/jwks`,
verifyOptions: {
issuer: `${authBaseUrl}/api/auth`,
audience: OAUTH_AUDIENCES,
+1
View File
@@ -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",