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; function getOAuthAudiences(): string[] { const base = authBaseUrl.replace(/\/$/, ""); return [base, `${base}/`, `${base}/mcp`, `${base}/mcp/`]; } const OAUTH_AUDIENCES = getOAuthAudiences(); export async function verifyOAuthToken(token: string): Promise { return await verifyAccessToken(token, { jwksUrl: `${authBaseUrl}/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: { 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, disableImplicitSignUp: true, 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, disableImplicitSignUp: true, 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, disableImplicitSignUp: true, 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();