Files
Reactive-Resume/packages/auth/src/config.ts
T
Santhi PrakashandAmruth Pillai 7eb6d3bdbf 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>
2026-08-13 22:51:02 +02:00

287 lines
9.5 KiB
TypeScript

import type { GenericOAuthConfig } from "better-auth/plugins";
import type { JWTPayload } from "jose";
import { apiKey } from "@better-auth/api-key";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { dash } from "@better-auth/infra";
import { oauthProvider } from "@better-auth/oauth-provider";
import { passkey } from "@better-auth/passkey";
import { compare, hash } from "bcrypt";
import { APIError, betterAuth } from "better-auth";
import { createAuthMiddleware } from "better-auth/api";
import { verifyAccessToken } from "better-auth/oauth2";
import { admin, jwt } from "better-auth/plugins";
import { genericOAuth } from "better-auth/plugins/generic-oauth";
import { twoFactor } from "better-auth/plugins/two-factor";
import { username } from "better-auth/plugins/username";
import { createElement } from "react";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { ResetPasswordEmail, VerifyEmail, VerifyEmailChange } from "@reactive-resume/email/templates/auth";
import { sendEmail } from "@reactive-resume/email/transport";
import { env } from "@reactive-resume/env/server";
import { rateLimitConfig, TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
import { generateId, toUsername } from "@reactive-resume/utils/string";
import { isAllowedOAuthRedirectUri } from "@reactive-resume/utils/url-security.node";
import { createGithubProfileMapper, createProfileMapper } from "./oauth-profile";
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,
`${oauthAudienceBase}/`,
`${oauthAudienceBase}/mcp`,
`${oauthAudienceBase}/mcp/`,
];
export function verifyOAuthToken(token: string): Promise<JWTPayload> {
return verifyAccessToken(token, {
jwksUrl: `${internalBaseUrl}/api/auth/jwks`,
verifyOptions: {
issuer: `${authBaseUrl}/api/auth`,
audience: OAUTH_AUDIENCES,
},
});
}
function isCustomOAuthProviderEnabled() {
const hasDiscovery = Boolean(env.OAUTH_DISCOVERY_URL);
const hasManual =
Boolean(env.OAUTH_AUTHORIZATION_URL) && Boolean(env.OAUTH_TOKEN_URL) && Boolean(env.OAUTH_USER_INFO_URL);
return Boolean(env.OAUTH_CLIENT_ID) && Boolean(env.OAUTH_CLIENT_SECRET) && (hasDiscovery || hasManual);
}
const TRUSTED_ORIGINS = getTrustedOrigins(env.APP_URL);
const oauthProviderRateLimit = isRateLimitEnabled
? rateLimitConfig.betterAuth.oauthProvider
: ({
register: false,
authorize: false,
token: false,
introspect: false,
revoke: false,
userinfo: false,
} as const);
const getAuthConfig = () => {
const authConfigs: GenericOAuthConfig[] = [];
if (isCustomOAuthProviderEnabled()) {
authConfigs.push({
providerId: "custom",
disableSignUp: env.FLAG_DISABLE_SIGNUPS,
clientId: env.OAUTH_CLIENT_ID as string,
clientSecret: env.OAUTH_CLIENT_SECRET as string,
discoveryUrl: env.OAUTH_DISCOVERY_URL,
authorizationUrl: env.OAUTH_AUTHORIZATION_URL,
tokenUrl: env.OAUTH_TOKEN_URL,
userInfoUrl: env.OAUTH_USER_INFO_URL,
scopes: env.OAUTH_SCOPES,
redirectURI: `${authBaseUrl}/api/auth/oauth2/callback/custom`,
mapProfileToUser: createProfileMapper({
providerName: "OAuth Provider",
getPreferredUsername: (profile, context) => profile.preferred_username ?? context.emailLocalPart,
getName: (profile, context) => profile.name ?? profile.preferred_username ?? context.emailLocalPart,
getImage: (profile) => profile.image ?? profile.picture ?? profile.avatar_url,
}),
} satisfies GenericOAuthConfig);
}
return betterAuth({
appName: "Reactive Resume",
baseURL: authBaseUrl,
secret: env.AUTH_SECRET,
database: drizzleAdapter(db, { schema, provider: "pg" }),
telemetry: { enabled: false },
trustedOrigins: TRUSTED_ORIGINS,
rateLimit: {
...rateLimitConfig.betterAuth.global,
enabled: isRateLimitEnabled,
},
hooks: {
// biome-ignore lint/suspicious/useAwait: Better Auth requires middleware callbacks to return a Promise.
before: createAuthMiddleware(async (ctx) => {
if (!ctx.path.includes("/oauth2/register")) return;
const body = ctx.body as { redirect_uris?: unknown } | undefined;
const redirectUris = Array.isArray(body?.redirect_uris) ? body.redirect_uris : [];
for (const uri of redirectUris) {
if (typeof uri !== "string") {
throw new APIError("BAD_REQUEST", { message: "redirect_uris entries must be strings" });
}
if (
!isAllowedOAuthRedirectUri(uri, TRUSTED_ORIGINS, {
allowUnsafe: env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI,
})
) {
throw new APIError("BAD_REQUEST", {
message: "redirect_uri is not allowed for dynamic client registration",
});
}
}
}),
},
advanced: {
database: { generateId },
useSecureCookies: authBaseUrl.startsWith("https://"),
ipAddress: { ipAddressHeaders: TRUSTED_IP_HEADERS },
},
emailAndPassword: {
enabled: !env.FLAG_DISABLE_EMAIL_AUTH,
autoSignIn: true,
minPasswordLength: 8,
maxPasswordLength: 64,
requireEmailVerification: false,
disableSignUp: env.FLAG_DISABLE_SIGNUPS || env.FLAG_DISABLE_EMAIL_AUTH,
sendResetPassword: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: "Reset your password",
react: createElement(ResetPasswordEmail, { url }),
});
},
password: {
hash: (password) => hash(password, 10),
verify: ({ password, hash }) => compare(password, hash),
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: "Verify your email",
react: createElement(VerifyEmail, { url }),
});
},
},
user: {
changeEmail: {
enabled: true,
sendChangeEmailConfirmation: async ({ user, newEmail, url }) => {
await sendEmail({
to: newEmail,
subject: "Verify your new email",
react: createElement(VerifyEmailChange, { url, previousEmail: user.email, newEmail }),
});
},
},
additionalFields: {
username: {
type: "string",
required: true,
},
},
},
account: {
accountLinking: {
enabled: true,
trustedProviders: ["google", "github", "linkedin"],
},
},
socialProviders: {
google: {
enabled: !!env.GOOGLE_CLIENT_ID && !!env.GOOGLE_CLIENT_SECRET,
disableSignUp: env.FLAG_DISABLE_SIGNUPS,
clientId: env.GOOGLE_CLIENT_ID ?? "",
clientSecret: env.GOOGLE_CLIENT_SECRET ?? "",
mapProfileToUser: createProfileMapper({
providerName: "Google",
getName: (profile, context) => profile.name ?? context.emailLocalPart,
getImage: (profile) => profile.picture,
}),
},
github: {
enabled: !!env.GITHUB_CLIENT_ID && !!env.GITHUB_CLIENT_SECRET,
disableSignUp: env.FLAG_DISABLE_SIGNUPS,
clientId: env.GITHUB_CLIENT_ID ?? "",
clientSecret: env.GITHUB_CLIENT_SECRET ?? "",
mapProfileToUser: createGithubProfileMapper(),
},
linkedin: {
enabled: !!env.LINKEDIN_CLIENT_ID && !!env.LINKEDIN_CLIENT_SECRET,
disableSignUp: env.FLAG_DISABLE_SIGNUPS,
clientId: env.LINKEDIN_CLIENT_ID ?? "",
clientSecret: env.LINKEDIN_CLIENT_SECRET ?? "",
mapProfileToUser: createProfileMapper({
providerName: "LinkedIn",
getName: (profile, context) => profile.name ?? context.emailLocalPart,
getImage: (profile) => profile.picture,
}),
},
},
plugins: [
jwt(),
admin(),
passkey(),
genericOAuth({ config: authConfigs }),
twoFactor({ issuer: "Reactive Resume" }),
apiKey({
enableSessionForAPIKeys: true,
rateLimit: {
...rateLimitConfig.betterAuth.apiKey,
enabled: isRateLimitEnabled,
},
}),
oauthProvider({
loginPage: "/api/auth/oauth",
consentPage: "/api/auth/oauth",
validAudiences: OAUTH_AUDIENCES,
allowDynamicClientRegistration: true,
// Required for MCP client onboarding (RFC 7591). Phishing vector is closed by the
// redirect_uri policy in the hooks.before middleware above and server auth preflight.
allowUnauthenticatedClientRegistration: true,
rateLimit: oauthProviderRateLimit,
silenceWarnings: { oauthAuthServerConfig: true },
}),
username({
minUsernameLength: 3,
maxUsernameLength: 64,
usernameNormalization: (value) => toUsername(value),
displayUsernameNormalization: (value) => toUsername(value),
usernameValidator: (username) => /^[a-z0-9._-]+$/.test(username),
validationOrder: { username: "post-normalization", displayUsername: "post-normalization" },
}),
...(env.BETTER_AUTH_API_KEY
? [dash({ apiKey: env.BETTER_AUTH_API_KEY, activityTracking: { enabled: true } })]
: []),
],
});
};
export const auth = getAuthConfig();