mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
- Added support for IdP metadata XML in the SAML registration dialog, allowing users to paste full metadata for configuration. - Updated the callback URL and audience handling to dynamically incorporate the base URL. - Refactored the SSO settings to enable SAML provider registration and improved the display of callback URLs based on provider details. - Enhanced trusted origins configuration in the authentication logic to include additional domains for development and production environments.
455 lines
11 KiB
TypeScript
455 lines
11 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 { IS_CLOUD } from "../constants";
|
|
import { db } from "../db";
|
|
import * as schema from "../db/schema";
|
|
import { getUserByToken } from "../services/admin";
|
|
import { getSSOProviders } from "../services/proprietary/sso";
|
|
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";
|
|
|
|
export const { handler, api } = betterAuth({
|
|
database: drizzleAdapter(db, {
|
|
provider: "pg",
|
|
schema: schema,
|
|
}),
|
|
disabledPaths: [
|
|
// "/sso/register",
|
|
"/organization/create",
|
|
"/organization/update",
|
|
"/organization/delete",
|
|
],
|
|
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",
|
|
},
|
|
// ...(!IS_CLOUD && {
|
|
async trustedOrigins() {
|
|
const settings = await getWebServerSettings();
|
|
if (!settings) {
|
|
return [];
|
|
}
|
|
|
|
const providers = await getSSOProviders();
|
|
const issuerOrigins = providers.map((provider) => provider.issuer);
|
|
|
|
return [
|
|
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
|
...(settings?.host ? [`https://${settings?.host}`] : []),
|
|
...issuerOrigins,
|
|
...(process.env.NODE_ENV === "development"
|
|
? [
|
|
"http://localhost:3000",
|
|
"https://absolutely-handy-falcon.ngrok-free.app",
|
|
"https://dev-pee8hhc3qbjlqedb.us.auth0.com",
|
|
"https://trial-2804699.okta.com",
|
|
"https://login.microsoftonline.com",
|
|
"https://graph.microsoft.com",
|
|
]
|
|
: []),
|
|
];
|
|
},
|
|
// Untrusted OIDC discovery URL: The main discovery endpoint "https://login.microsoftonline.com/9f26c287-38e9-4731-9d1d-506365a6cc8e/.well-known/openid-configuration" is not trusted by your trusted origins configuration.
|
|
|
|
// }),
|
|
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: "",
|
|
},
|
|
},
|
|
},
|
|
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,
|
|
},
|
|
};
|
|
|
|
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,
|
|
},
|
|
});
|
|
|
|
session.user.role = member?.role || "member";
|
|
if (member) {
|
|
session.user.ownerId = member.organization.ownerId;
|
|
} else {
|
|
session.user.ownerId = session.user.id;
|
|
}
|
|
}
|
|
|
|
return session;
|
|
};
|