mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-16 04:35:24 +02:00
- Updated the account linking configuration to include trusted providers fetched from the database, enhancing flexibility in managing SSO integrations.
489 lines
12 KiB
TypeScript
489 lines
12 KiB
TypeScript
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 query = await db.query.ssoProvider.findMany();
|
|
|
|
const trustedProviders = query.map((provider) => provider.providerId);
|
|
|
|
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: `
|
|
<p>Click the link to verify your email: <a href="${url}">Verify Email</a></p>
|
|
`,
|
|
});
|
|
}
|
|
},
|
|
},
|
|
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: `
|
|
<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) {
|
|
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: `
|
|
<p>You are invited to join ${data.organization.name} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
|
|
`,
|
|
});
|
|
}
|
|
},
|
|
}),
|
|
...(IS_CLOUD
|
|
? [
|
|
admin({
|
|
adminUserIds: [process.env.USER_ADMIN_ID as string],
|
|
}),
|
|
]
|
|
: []),
|
|
],
|
|
});
|
|
|
|
export const auth = {
|
|
handler,
|
|
createApiKey: api.createApiKey,
|
|
registerSSOProvider: api.registerSSOProvider,
|
|
};
|
|
|
|
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 || "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;
|
|
};
|