import type { IncomingMessage } from "node:http"; 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 } from "better-auth/api"; import { admin, apiKey, organization, twoFactor } from "better-auth/plugins"; import { and, desc, eq } from "drizzle-orm"; import { BETTER_AUTH_SECRET, IS_CLOUD } from "../constants"; import { db } from "../db"; import * as schema from "../db/schema"; import { getTrustedOrigins, getUserByToken } from "../services/admin"; import { getWebServerSettings, updateWebServerSettings, } from "../services/web-server-settings"; import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot"; import { sendEmail } from "../verification/send-verification-email"; import { getPublicIpWithFallback } from "../wss/utils"; const trustedProviders = process.env?.TRUSTED_PROVIDERS?.split(",") || []; const { handler, api } = betterAuth({ database: drizzleAdapter(db, { provider: "pg", schema: schema, }), disabledPaths: [ "/sso/register", "/organization/create", "/organization/update", "/organization/delete", ], secret: BETTER_AUTH_SECRET, ...(!IS_CLOUD ? { advanced: { useSecureCookies: false, defaultCookieAttributes: { sameSite: "lax", secure: false, httpOnly: true, path: "/", }, }, } : {}), account: { accountLinking: { enabled: true, trustedProviders: ["github", "google", ...(trustedProviders || [])], 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", }, async trustedOrigins() { const trustedOrigins = await getTrustedOrigins(); if (IS_CLOUD) { return trustedOrigins; } const settings = await getWebServerSettings(); if (!settings) { return []; } return [ ...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []), ...(settings?.host ? [`https://${settings?.host}`] : []), ...(process.env.NODE_ENV === "development" ? [ "http://localhost:3000", "https://absolutely-handy-falcon.ngrok-free.app", ] : []), ...trustedOrigins, ]; }, emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: true, sendVerificationEmail: async ({ user, url }) => { if (IS_CLOUD) { await sendEmail({ email: user.email, subject: "Verify your email", text: `

Click the link to verify your email: Verify Email

`, }); } }, }, emailAndPassword: { enabled: true, autoSignIn: !IS_CLOUD, requireEmailVerification: IS_CLOUD, 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: `

Click the link to reset your password: Reset Password

`, }); }, }, databaseHooks: { user: { create: { before: async (_user, context) => { if (!IS_CLOUD) { const xDokployToken = context?.request?.headers?.get("x-dokploy-token"); if (xDokployToken) { const user = await getUserByToken(xDokployToken); if (!user) { throw new APIError("BAD_REQUEST", { message: "User not found", }); } } else { const isSSORequest = context?.path.includes("/sso"); if (isSSORequest) { 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 isAdminPresent = await db.query.member.findFirst({ where: eq(schema.member.role, "owner"), }); if (!IS_CLOUD) { 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 (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, }, }; }, }, }, }, 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, }), sso(), twoFactor(), organization({ async sendInvitationEmail(data, _request) { if (IS_CLOUD) { const host = process.env.NODE_ENV === "development" ? "http://localhost:3000" : "https://app.dokploy.com"; const inviteLink = `${host}/invitation?token=${data.id}`; await sendEmail({ email: data.email, subject: "Invitation to join organization", text: `

You are invited to join ${data.organization.name} on Dokploy. Click the link to accept the invitation: Accept Invitation

`, }); } }, }), ...(IS_CLOUD ? [ admin({ adminUserIds: [process.env.USER_ADMIN_ID as string], }), ] : []), ], }); export const auth = { handler, createApiKey: api.createApiKey, registerSSOProvider: api.registerSSOProvider, updateSSOProvider: api.updateSSOProvider, }; 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 { session: null, user: null, }; } const apiKeyRecord = await db.query.apikey.findFirst({ where: eq(schema.apikey.id, key.id), with: { user: true, }, }); if (!apiKeyRecord) { return { session: null, user: null, }; } const organizationId = JSON.parse( apiKeyRecord.metadata || "{}", ).organizationId; if (!organizationId) { return { session: null, user: null, }; } const member = await db.query.member.findFirst({ where: and( eq(schema.member.userId, apiKeyRecord.user.id), eq(schema.member.organizationId, organizationId), ), with: { organization: true, }, }); // When accessing from DB, use actual column names const userFromDb = apiKeyRecord.user as typeof apiKeyRecord.user & { firstName: string; lastName: string; }; const mockSession = { session: { userId: apiKeyRecord.user.id, activeOrganizationId: organizationId || "", }, user: { id: userFromDb.id, name: userFromDb.firstName, // Map firstName back to name for better-auth email: userFromDb.email, emailVerified: userFromDb.emailVerified, image: userFromDb.image, createdAt: userFromDb.createdAt, updatedAt: userFromDb.updatedAt, twoFactorEnabled: userFromDb.twoFactorEnabled, role: member?.role || "member", ownerId: member?.organization.ownerId || apiKeyRecord.user.id, enableEnterpriseFeatures: userFromDb.enableEnterpriseFeatures, isValidEnterpriseLicense: userFromDb.isValidEnterpriseLicense, }, }; return mockSession; } catch (error) { console.error("Error verifying API key", error); return { session: null, user: null, }; } } // If no API key, proceed with normal session validation const session = await api.getSession({ headers: new Headers({ cookie: request.headers.cookie || "", }), }); if (!session?.session || !session.user) { return { session: null, user: null, }; } if (session?.user) { const member = await db.query.member.findFirst({ where: and( eq(schema.member.userId, session.user.id), eq( schema.member.organizationId, session.session.activeOrganizationId || "", ), ), with: { organization: true, user: true, }, }); session.user.role = member?.role || "member"; session.user.enableEnterpriseFeatures = member?.user.enableEnterpriseFeatures || false; session.user.isValidEnterpriseLicense = member?.user.isValidEnterpriseLicense || false; if (member) { session.user.ownerId = member.organization.ownerId; } else { session.user.ownerId = session.user.id; } } return session; };