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",