628 lines · 16.2 KB · TypeScript
RawDownload
import type { IncomingMessage } from "node:http";import { apiKey } from "@better-auth/api-key";import { scim } from "@better-auth/scim";import { sso } from "@better-auth/sso";import * as bcrypt from "bcrypt";import { betterAuth } from "better-auth";import { drizzleAdapter } from "better-auth/adapters/drizzle";import { APIError, createAuthMiddleware } from "better-auth/api";import { admin, organization, twoFactor } from "better-auth/plugins";import { and, desc, eq } from "drizzle-orm";import { IS_CLOUD } from "../constants";import { db } from "../db";import * as schema from "../db/schema";import {	getTrustedOrigins,	getTrustedProviders,	getUserByToken,} from "../services/admin";import { createAuditLog } from "../services/proprietary/audit-log";import {	getWebServerSettings,	updateWebServerSettings,} from "../services/web-server-settings";import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";import {	sendEmail,	sendVerificationEmail,} from "../verification/send-verification-email";import { getPublicIpWithFallback } from "../wss/utils";import { ac, adminRole, memberRole, ownerRole } from "./access-control";import { betterAuthSecret } from "./auth-secret"; const resolveTrustedOrigins = async () => {	try {		if (IS_CLOUD) {			return await getTrustedOrigins();		}		const [trustedOrigins, settings] = await Promise.all([			getTrustedOrigins(),			getWebServerSettings(),		]); 		if (!settings) return []; 		const devOrigins =			process.env.NODE_ENV === "development"				? [						"http://localhost:3000",						"https://absolutely-handy-falcon.ngrok-free.app",					]				: [];		return [			...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),			...(settings?.host ? [`https://${settings?.host}`] : []),			...devOrigins,			...trustedOrigins,		];	} catch (error) {		console.error("Failed to resolve trusted origins:", error);		return [];	}}; const createBetterAuth = () =>	betterAuth({		database: drizzleAdapter(db, {			provider: "pg",			schema: schema,		}),		disabledPaths: [			"/sso/register",			"/organization/create",			"/organization/update",			"/organization/delete",			...(!IS_CLOUD ? ["/verify-email"] : []),		],		secret: betterAuthSecret,		...(!IS_CLOUD			? {					advanced: {						useSecureCookies: false,						defaultCookieAttributes: {							sameSite: "lax",							secure: false,							httpOnly: true,							path: "/",						},					},				}			: {}), 		account: {			accountLinking: {				enabled: true,				async trustedProviders() {					const fromDb = await getTrustedProviders();					return ["github", "google", ...fromDb];				},				allowDifferentEmails: true,			},		},		appName: "Dokploy",		socialProviders: {			github: {				clientId: process.env.GITHUB_CLIENT_ID as string,				clientSecret: process.env.GITHUB_CLIENT_SECRET as string,			},			google: {				clientId: process.env.GOOGLE_CLIENT_ID as string,				clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,			},		},		logger: {			disabled: process.env.NODE_ENV === "production",		},		trustedOrigins: resolveTrustedOrigins,		hooks: {			before: createAuthMiddleware(async (ctx) => {				ctx.context.trustedOrigins = [					...(ctx.context.baseURL ? [new URL(ctx.context.baseURL).origin] : []),					...(await resolveTrustedOrigins()),				].filter(Boolean);			}),		},		emailVerification: {			sendOnSignUp: true,			autoSignInAfterVerification: true,			sendOnSignIn: true,			sendVerificationEmail: async ({ user, url }) => {				if (IS_CLOUD) {					await sendVerificationEmail({						userName: user.name || "User",						email: user.email,						verificationUrl: url,					});				}			},		},		emailAndPassword: {			enabled: true,			autoSignIn: !IS_CLOUD,			requireEmailVerification:				IS_CLOUD && process.env.NODE_ENV === "production",			password: {				async hash(password) {					return bcrypt.hashSync(password, 10);				},				async verify({ hash, password }) {					return bcrypt.compareSync(password, hash);				},			},			sendResetPassword: async ({ user, url }) => {				await sendEmail({					email: user.email,					subject: "Reset your password",					text: `				<p>Click the link to reset your password: <a href="${url}">Reset Password</a></p>				`,				});			},		},		databaseHooks: {			user: {				create: {					before: async (_user, context) => {						if (!IS_CLOUD) {							const xDokployToken =								context?.request?.headers?.get("x-dokploy-token");							if (xDokployToken) {								let invitation: Awaited<ReturnType<typeof getUserByToken>>;								try {									invitation = await getUserByToken(xDokployToken);								} catch {									throw new APIError("BAD_REQUEST", {										message: "Invalid invitation token",									});								}								if (invitation.isExpired) {									throw new APIError("BAD_REQUEST", {										message: "Invitation has expired",									});								}								if (invitation.status !== "pending") {									throw new APIError("BAD_REQUEST", {										message: "Invitation has already been used",									});								}								if (									_user.email.toLowerCase().trim() !==									invitation.email.toLowerCase().trim()								) {									throw new APIError("BAD_REQUEST", {										message: "Email does not match invitation",									});								}							} else {								const isSSORequest = context?.path.includes("/sso");								const isSCIMRequest = context?.path.includes("/scim");								if (isSSORequest || isSCIMRequest) {									return;								}								const isAdminPresent = await db.query.member.findFirst({									where: eq(schema.member.role, "owner"),								});								if (isAdminPresent) {									throw new APIError("BAD_REQUEST", {										message: "Admin is already created",									});								}							}						}					},					after: async (user, context) => {						const isSSORequest = context?.path.includes("/sso");						const isSCIMRequest = context?.path.includes("/scim");						const isAdminPresent = await db.query.member.findFirst({							where: eq(schema.member.role, "owner"),						}); 						if (!IS_CLOUD && !isAdminPresent) {							await updateWebServerSettings({								serverIp: await getPublicIpWithFallback(),							});						} 						if (IS_CLOUD) {							try {								const hutk = getHubSpotUTK(									context?.request?.headers?.get("cookie") || undefined,								);								// Cast to include additional fields								const userWithFields = user as typeof user & {									lastName?: string;								};								const hubspotSuccess = await submitToHubSpot(									{										email: user.email,										firstName: user.name || "", // name is mapped to firstName column										lastName: userWithFields.lastName || "",									},									hutk,								);								if (!hubspotSuccess) {									console.error("Failed to submit to HubSpot");								}							} catch (error) {								console.error("Error submitting to HubSpot", error);							}						} 						if (isSCIMRequest) {							return;						} 						if (IS_CLOUD || !isAdminPresent) {							await db.transaction(async (tx) => {								const organization = await tx									.insert(schema.organization)									.values({										name: "My Organization",										ownerId: user.id,										createdAt: new Date(),									})									.returning()									.then((res) => res[0]); 								await tx.insert(schema.member).values({									userId: user.id,									organizationId: organization?.id || "",									role: "owner",									createdAt: new Date(),									isDefault: true, // Mark first organization as default								});							});						} else if (isSSORequest) {							const providerId = context?.params?.providerId;							if (!providerId) {								throw new APIError("BAD_REQUEST", {									message: "Provider ID is required",								});							}							const provider = await db.query.ssoProvider.findFirst({								where: eq(schema.ssoProvider.providerId, providerId),							}); 							if (!provider) {								throw new APIError("BAD_REQUEST", {									message: "Provider not found",								});							}							await db.insert(schema.member).values({								userId: user.id,								organizationId: provider?.organizationId || "",								role: "member",								createdAt: new Date(),								isDefault: true,							});						}					},				},			},			session: {				create: {					before: async (session) => {						// Find the default organization for this user						// Priority: 1) isDefault=true, 2) most recently created						const member = await db.query.member.findFirst({							where: eq(schema.member.userId, session.userId),							orderBy: [								desc(schema.member.isDefault),								desc(schema.member.createdAt),							],							with: {								organization: true,							},						}); 						return {							data: {								...session,								activeOrganizationId: member?.organization.id,							},						};					},					after: async (session) => {						const orgId = (							session as typeof session & { activeOrganizationId?: string }						).activeOrganizationId;						if (!orgId) return;						const memberRecord = await db.query.member.findFirst({							where: and(								eq(schema.member.userId, session.userId),								eq(schema.member.organizationId, orgId),							),							with: { user: true },						});						if (!memberRecord) return;						await createAuditLog({							organizationId: orgId,							userId: session.userId,							userEmail: memberRecord.user.email,							userRole: memberRecord.role,							action: "login",							resourceType: "session",						});					},				},				delete: {					after: async (session) => {						const orgId = (							session as typeof session & { activeOrganizationId?: string }						).activeOrganizationId;						if (!orgId) return;						const memberRecord = await db.query.member.findFirst({							where: and(								eq(schema.member.userId, session.userId),								eq(schema.member.organizationId, orgId),							),							with: { user: true },						});						if (!memberRecord) return;						await createAuditLog({							organizationId: orgId,							userId: session.userId,							userEmail: memberRecord.user.email,							userRole: memberRecord.role,							action: "logout",							resourceType: "session",						});					},				},			},		},		session: {			expiresIn: 60 * 60 * 24 * 3,			updateAge: 60 * 60 * 24,		},		user: {			modelName: "user",			fields: {				name: "firstName", // Map better-auth's default 'name' field to 'firstName' column			},			additionalFields: {				role: {					type: "string",					// required: true,					input: false,				},				ownerId: {					type: "string",					// required: true,					input: false,				},				allowImpersonation: {					fieldName: "allowImpersonation",					type: "boolean",					defaultValue: false,				},				lastName: {					type: "string",					required: false,					input: true,					defaultValue: "",				},				enableEnterpriseFeatures: {					type: "boolean",					required: false,					input: false,				},				isValidEnterpriseLicense: {					type: "boolean",					required: false,					input: false,				},			},		},		plugins: [			apiKey({				enableMetadata: true,				references: "user",			}),			sso(),			scim({				beforeSCIMTokenGenerated: async ({ user }) => {					const dbUser = await db.query.user.findFirst({						where: eq(schema.user.id, user.id),						columns: { enableEnterpriseFeatures: true },					}); 					if (!dbUser?.enableEnterpriseFeatures) {						throw new APIError("FORBIDDEN", {							message: "SCIM provisioning requires an enterprise license",						});					}				},			}),			twoFactor(),			organization({				ac,				roles: {					owner: ownerRole,					admin: adminRole,					member: memberRole,				},				dynamicAccessControl: {					enabled: true,					maximumRolesPerOrganization: 10,				},			}),			// Self-hosted needs the admin plugin too: SCIM deactivation (active: false)			// maps to the admin plugin's `banned` field and is rejected without it.			// adminRoles: [] keeps every /admin/* endpoint locked on self-hosted.			admin(				IS_CLOUD					? { adminUserIds: [process.env.USER_ADMIN_ID as string] }					: { adminRoles: [] },			),		],	}); // Una sola instancia de better-auth por proceso aunque el módulo esté// duplicado en varios bundles.const globalForAuth = globalThis as unknown as {	betterAuthInstance?: ReturnType<typeof createBetterAuth>;}; if (!globalForAuth.betterAuthInstance) {	globalForAuth.betterAuthInstance = createBetterAuth();} const { handler, api } = globalForAuth.betterAuthInstance; const _auth = {	handler,	createApiKey: api.createApiKey,	registerSSOProvider: api.registerSSOProvider,	updateSSOProvider: api.updateSSOProvider,	generateSCIMToken: api.generateSCIMToken,	listSCIMProviderConnections: api.listSCIMProviderConnections,	deleteSCIMProviderConnection: api.deleteSCIMProviderConnection,}; export type AuthType = typeof _auth;export const auth: AuthType = _auth; export const validateRequest = async (request: IncomingMessage) => {	const apiKey = request.headers["x-api-key"] as string;	if (apiKey) {		try {			const { valid, key, error } = await api.verifyApiKey({				body: {					key: apiKey,				},			}); 			if (error) {				throw new Error(error.message?.toString() || "Error verifying API key");			}			if (!valid || !key) {				return {

Lines 1–500 of 628.