diff --git a/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts
index 0dbeaaaad9..06c90514f8 100644
--- a/apps/dokploy/server/api/root.ts
+++ b/apps/dokploy/server/api/root.ts
@@ -30,6 +30,7 @@ import { previewDeploymentRouter } from "./routers/preview-deployment";
import { projectRouter } from "./routers/project";
import { auditLogRouter } from "./routers/proprietary/audit-log";
import { customRoleRouter } from "./routers/proprietary/custom-role";
+import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
@@ -93,6 +94,7 @@ export const appRouter = createTRPCRouter({
organization: organizationRouter,
licenseKey: licenseKeyRouter,
sso: ssoRouter,
+ forwardAuth: forwardAuthRouter,
whitelabeling: whitelabelingRouter,
customRole: customRoleRouter,
auditLog: auditLogRouter,
diff --git a/apps/dokploy/server/api/routers/proprietary/forward-auth.ts b/apps/dokploy/server/api/routers/proprietary/forward-auth.ts
new file mode 100644
index 0000000000..77caae150f
--- /dev/null
+++ b/apps/dokploy/server/api/routers/proprietary/forward-auth.ts
@@ -0,0 +1,207 @@
+import {
+ assertApplicationDomainAccess,
+ deployForwardAuthOnServer,
+ disableForwardAuthOnDomain,
+ enableForwardAuthOnDomain,
+ findServerById,
+ forwardAuthCallbackUrl,
+ getDomainSsoStatus,
+ getForwardAuthServerStatus,
+ getForwardAuthSettings,
+ listSsoProvidersForOrg,
+ removeForwardAuthProxy,
+ removeForwardAuthSettings,
+ setForwardAuthSettings,
+} from "@dokploy/server";
+import {
+ apiDeployForwardAuthOnServer,
+ apiForwardAuthDomainTarget,
+ apiForwardAuthServerTarget,
+ apiSetForwardAuthSettings,
+} from "@dokploy/server/db/schema";
+import { TRPCError } from "@trpc/server";
+import {
+ createTRPCRouter,
+ enterpriseProcedure,
+ withPermission,
+} from "@/server/api/trpc";
+import { audit } from "@/server/api/utils/audit";
+
+export const forwardAuthRouter = createTRPCRouter({
+ getAuthDomain: enterpriseProcedure
+ .input(apiForwardAuthServerTarget)
+ .query(async ({ ctx, input }) => {
+ if (input.serverId) {
+ const server = await findServerById(input.serverId);
+ if (server.organizationId !== ctx.session?.activeOrganizationId) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this server",
+ });
+ }
+ }
+ const settings = await getForwardAuthSettings(input.serverId);
+ if (!settings) return null;
+ return {
+ host: settings.authDomain,
+ https: settings.https,
+ certificateType: settings.certificateType,
+ customCertResolver: settings.customCertResolver,
+ callbackUrl: forwardAuthCallbackUrl(
+ settings.authDomain,
+ settings.https,
+ ),
+ };
+ }),
+
+ setAuthDomain: enterpriseProcedure
+ .input(apiSetForwardAuthSettings)
+ .mutation(async ({ ctx, input }) => {
+ if (input.serverId) {
+ const server = await findServerById(input.serverId);
+ if (server.organizationId !== ctx.session?.activeOrganizationId) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this server",
+ });
+ }
+ }
+ const result = await setForwardAuthSettings({
+ organizationId: ctx.session.activeOrganizationId,
+ serverId: input.serverId,
+ authDomain: input.authDomain,
+ https: input.https,
+ certificateType: input.certificateType,
+ customCertResolver: input.customCertResolver,
+ });
+ await audit(ctx, {
+ action: "update",
+ resourceType: "server",
+ resourceId: input.serverId ?? "local",
+ resourceName: "forward-auth-domain",
+ });
+ return result;
+ }),
+
+ removeAuthDomain: enterpriseProcedure
+ .input(apiForwardAuthServerTarget)
+ .mutation(async ({ ctx, input }) => {
+ if (input.serverId) {
+ const server = await findServerById(input.serverId);
+ if (server.organizationId !== ctx.session?.activeOrganizationId) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this server",
+ });
+ }
+ }
+ const result = await removeForwardAuthSettings(input.serverId);
+ await audit(ctx, {
+ action: "delete",
+ resourceType: "server",
+ resourceId: input.serverId ?? "local",
+ resourceName: "forward-auth-domain",
+ });
+ return result;
+ }),
+
+ listProviders: enterpriseProcedure.query(({ ctx }) =>
+ listSsoProvidersForOrg(ctx.session.activeOrganizationId),
+ ),
+
+ serverStatus: enterpriseProcedure.query(({ ctx }) =>
+ getForwardAuthServerStatus(ctx.session.activeOrganizationId),
+ ),
+
+ deployOnServer: enterpriseProcedure
+ .input(apiDeployForwardAuthOnServer)
+ .mutation(async ({ ctx, input }) => {
+ if (input.serverId) {
+ const server = await findServerById(input.serverId);
+ if (server.organizationId !== ctx.session?.activeOrganizationId) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this server",
+ });
+ }
+ }
+ const result = await deployForwardAuthOnServer({
+ serverId: input.serverId ?? undefined,
+ providerId: input.providerId,
+ organizationId: ctx.session.activeOrganizationId,
+ });
+ await audit(ctx, {
+ action: "create",
+ resourceType: "server",
+ resourceId: input.serverId ?? "local",
+ resourceName: "forward-auth",
+ });
+ return result;
+ }),
+
+ removeOnServer: enterpriseProcedure
+ .input(apiForwardAuthServerTarget)
+ .mutation(async ({ ctx, input }) => {
+ if (input.serverId) {
+ const server = await findServerById(input.serverId);
+ if (server.organizationId !== ctx.session?.activeOrganizationId) {
+ throw new TRPCError({
+ code: "UNAUTHORIZED",
+ message: "You are not authorized to access this server",
+ });
+ }
+ }
+ const result = await removeForwardAuthProxy(input.serverId);
+ await audit(ctx, {
+ action: "delete",
+ resourceType: "server",
+ resourceId: input.serverId ?? "local",
+ resourceName: "forward-auth",
+ });
+ return result;
+ }),
+
+ status: withPermission("domain", "read")
+ .input(apiForwardAuthDomainTarget)
+ .query(({ ctx, input }) => getDomainSsoStatus(ctx, input.domainId)),
+
+ enable: withPermission("domain", "create")
+ .input(apiForwardAuthDomainTarget)
+ .mutation(async ({ ctx, input }) => {
+ const domain = await assertApplicationDomainAccess(
+ ctx,
+ input.domainId,
+ "create",
+ );
+ const result = await enableForwardAuthOnDomain({
+ domainId: input.domainId,
+ });
+ await audit(ctx, {
+ action: "update",
+ resourceType: "domain",
+ resourceId: domain.domainId,
+ resourceName: domain.host,
+ });
+ return result;
+ }),
+
+ disable: withPermission("domain", "create")
+ .input(apiForwardAuthDomainTarget)
+ .mutation(async ({ ctx, input }) => {
+ const domain = await assertApplicationDomainAccess(
+ ctx,
+ input.domainId,
+ "create",
+ );
+ const result = await disableForwardAuthOnDomain({
+ domainId: input.domainId,
+ });
+ await audit(ctx, {
+ action: "update",
+ resourceType: "domain",
+ resourceId: domain.domainId,
+ resourceName: domain.host,
+ });
+ return result;
+ }),
+});
diff --git a/apps/dokploy/server/api/routers/proprietary/sso.ts b/apps/dokploy/server/api/routers/proprietary/sso.ts
index ca13cc4709..84a79223de 100644
--- a/apps/dokploy/server/api/routers/proprietary/sso.ts
+++ b/apps/dokploy/server/api/routers/proprietary/sso.ts
@@ -53,10 +53,7 @@ export const ssoRouter = createTRPCRouter({
}),
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
const providers = await db.query.ssoProvider.findMany({
- where: and(
- eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
- eq(ssoProvider.userId, ctx.session.userId),
- ),
+ where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
columns: {
id: true,
providerId: true,
@@ -88,7 +85,6 @@ export const ssoRouter = createTRPCRouter({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
- eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
@@ -116,12 +112,12 @@ export const ssoRouter = createTRPCRouter({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
- eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
issuer: true,
domain: true,
+ userId: true,
},
});
@@ -133,6 +129,13 @@ export const ssoRouter = createTRPCRouter({
});
}
+ if (existing.userId !== ctx.session.userId) {
+ await db
+ .update(ssoProvider)
+ .set({ userId: ctx.session.userId })
+ .where(eq(ssoProvider.id, existing.id));
+ }
+
const providers = await db.query.ssoProvider.findMany({
where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
columns: { providerId: true, domain: true },
@@ -218,7 +221,6 @@ export const ssoRouter = createTRPCRouter({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
- eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
@@ -241,7 +243,6 @@ export const ssoRouter = createTRPCRouter({
and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
- eq(ssoProvider.userId, ctx.session.userId),
),
)
.returning({ id: ssoProvider.id });
diff --git a/packages/server/src/db/schema/domain.ts b/packages/server/src/db/schema/domain.ts
index 646dfdf9f2..092275fde2 100644
--- a/packages/server/src/db/schema/domain.ts
+++ b/packages/server/src/db/schema/domain.ts
@@ -55,6 +55,7 @@ export const domains = pgTable("domain", {
internalPath: text("internalPath").default("/"),
stripPath: boolean("stripPath").notNull().default(false),
middlewares: text("middlewares").array().default(sql`ARRAY[]::text[]`),
+ forwardAuthEnabled: boolean("forwardAuthEnabled").notNull().default(false),
});
export const domainsRelations = relations(domains, ({ one }) => ({
@@ -94,6 +95,7 @@ export const apiCreateDomain = createSchema.pick({
internalPath: true,
stripPath: true,
middlewares: true,
+ forwardAuthEnabled: true,
});
export const apiFindDomain = z.object({
@@ -126,5 +128,6 @@ export const apiUpdateDomain = createSchema
internalPath: true,
stripPath: true,
middlewares: true,
+ forwardAuthEnabled: true,
})
.merge(createSchema.pick({ domainId: true }).required());
diff --git a/packages/server/src/db/schema/forward-auth.ts b/packages/server/src/db/schema/forward-auth.ts
new file mode 100644
index 0000000000..47e7dec7ec
--- /dev/null
+++ b/packages/server/src/db/schema/forward-auth.ts
@@ -0,0 +1,75 @@
+import { relations } from "drizzle-orm";
+import { boolean, pgTable, text } from "drizzle-orm/pg-core";
+import { nanoid } from "nanoid";
+import { z } from "zod";
+import { server } from "./server";
+import { certificateType } from "./shared";
+import { ssoProvider } from "./sso";
+
+export const forwardAuthSettings = pgTable("forward_auth_settings", {
+ forwardAuthSettingsId: text("forwardAuthSettingsId")
+ .notNull()
+ .primaryKey()
+ .$defaultFn(() => nanoid()),
+ authDomain: text("authDomain").notNull(),
+ baseDomain: text("baseDomain").notNull(),
+ https: boolean("https").notNull().default(true),
+ certificateType: certificateType("certificateType")
+ .notNull()
+ .default("letsencrypt"),
+ customCertResolver: text("customCertResolver"),
+ providerId: text("providerId").references(() => ssoProvider.providerId, {
+ onDelete: "set null",
+ }),
+ serverId: text("serverId")
+ .unique()
+ .references(() => server.serverId, {
+ onDelete: "cascade",
+ }),
+ createdAt: text("createdAt")
+ .notNull()
+ .$defaultFn(() => new Date().toISOString()),
+});
+
+export const forwardAuthSettingsRelations = relations(
+ forwardAuthSettings,
+ ({ one }) => ({
+ server: one(server, {
+ fields: [forwardAuthSettings.serverId],
+ references: [server.serverId],
+ }),
+ provider: one(ssoProvider, {
+ fields: [forwardAuthSettings.providerId],
+ references: [ssoProvider.providerId],
+ }),
+ }),
+);
+
+const domainRegex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;
+
+export const apiForwardAuthServerTarget = z.object({
+ serverId: z.string().nullable(),
+});
+
+export const apiForwardAuthDomainTarget = z.object({
+ domainId: z.string().min(1),
+});
+
+export const apiSetForwardAuthSettings = z.object({
+ serverId: z.string().nullable(),
+ authDomain: z
+ .string()
+ .trim()
+ .toLowerCase()
+ .refine((v) => domainRegex.test(v), { message: "Invalid auth domain" }),
+ https: z.boolean().default(true),
+ certificateType: z
+ .enum(["none", "letsencrypt", "custom"])
+ .default("letsencrypt"),
+ customCertResolver: z.string().optional(),
+});
+
+export const apiDeployForwardAuthOnServer = z.object({
+ serverId: z.string().nullable(),
+ providerId: z.string().min(1),
+});
diff --git a/packages/server/src/db/schema/index.ts b/packages/server/src/db/schema/index.ts
index a4e613a023..b00bed2003 100644
--- a/packages/server/src/db/schema/index.ts
+++ b/packages/server/src/db/schema/index.ts
@@ -10,6 +10,7 @@ export * from "./deployment";
export * from "./destination";
export * from "./domain";
export * from "./environment";
+export * from "./forward-auth";
export * from "./git-provider";
export * from "./gitea";
export * from "./github";
diff --git a/packages/server/src/db/schema/sso.ts b/packages/server/src/db/schema/sso.ts
index e95872fd44..502c9fcfae 100644
--- a/packages/server/src/db/schema/sso.ts
+++ b/packages/server/src/db/schema/sso.ts
@@ -10,7 +10,7 @@ export const ssoProvider = pgTable("sso_provider", {
oidcConfig: text("oidc_config"),
samlConfig: text("saml_config"),
providerId: text("provider_id").notNull().unique(),
- userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
+ userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
organizationId: text("organization_id").references(() => organization.id, {
onDelete: "cascade",
}),
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index dd627deaf3..7bda4615ad 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -35,6 +35,7 @@ export * from "./services/port";
export * from "./services/postgres";
export * from "./services/preview-deployment";
export * from "./services/project";
+export * from "./services/proprietary/forward-auth";
export * from "./services/proprietary/license-key";
export * from "./services/proprietary/sso";
export * from "./services/redirect";
@@ -50,6 +51,7 @@ export * from "./services/user";
export * from "./services/volume-backups";
export * from "./services/web-server-settings";
export * from "./setup/config-paths";
+export * from "./setup/forward-auth-setup";
export * from "./setup/monitoring-setup";
export * from "./setup/postgres-setup";
export * from "./setup/redis-setup";
@@ -127,6 +129,7 @@ export * from "./utils/tracking/hubspot";
export * from "./utils/traefik/application";
export * from "./utils/traefik/domain";
export * from "./utils/traefik/file-types";
+export * from "./utils/traefik/forward-auth";
export * from "./utils/traefik/middleware";
export * from "./utils/traefik/redirect";
export * from "./utils/traefik/security";
diff --git a/packages/server/src/services/proprietary/forward-auth.ts b/packages/server/src/services/proprietary/forward-auth.ts
new file mode 100644
index 0000000000..de1e1847dc
--- /dev/null
+++ b/packages/server/src/services/proprietary/forward-auth.ts
@@ -0,0 +1,382 @@
+import { IS_CLOUD } from "@dokploy/server/constants";
+import { db } from "@dokploy/server/db";
+import {
+ forwardAuthSettings,
+ server,
+ ssoProvider,
+} from "@dokploy/server/db/schema";
+import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
+import {
+ deriveBaseDomain,
+ deriveCookieSecret,
+ type ForwardAuthOidcConfig,
+ forwardAuthCallbackUrl,
+ isForwardAuthRunning,
+ removeForwardAuth,
+ setupForwardAuth,
+} from "@dokploy/server/setup/forward-auth-setup";
+import { manageDomain } from "@dokploy/server/utils/traefik/domain";
+import {
+ manageForwardAuthDomain,
+ removeForwardAuthDomain,
+ removeForwardAuthMiddleware,
+} from "@dokploy/server/utils/traefik/forward-auth";
+import { TRPCError } from "@trpc/server";
+import { and, asc, desc, eq, isNotNull, isNull } from "drizzle-orm";
+import { findApplicationById } from "../application";
+import { findDomainById, updateDomainById } from "../domain";
+
+const resolveOidcConfig = (provider: {
+ issuer: string;
+ oidcConfig: string | null;
+}): ForwardAuthOidcConfig => {
+ if (!provider.oidcConfig) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Forward-auth requires an OIDC provider — SAML is not supported.",
+ });
+ }
+
+ let parsed: any;
+ try {
+ parsed = JSON.parse(provider.oidcConfig);
+ } catch {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to parse the SSO provider OIDC configuration",
+ });
+ }
+
+ if (!parsed?.clientId || !parsed?.clientSecret) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "SSO provider OIDC config is missing clientId/clientSecret",
+ });
+ }
+
+ return {
+ clientId: parsed.clientId,
+ clientSecret: parsed.clientSecret,
+ issuer: provider.issuer,
+ scopes: parsed.scopes,
+ skipDiscovery: parsed.skipDiscovery,
+ };
+};
+
+const findProviderForOrg = async (
+ providerId: string,
+ organizationId: string,
+) => {
+ const provider = await db.query.ssoProvider.findFirst({
+ where: and(
+ eq(ssoProvider.providerId, providerId),
+ eq(ssoProvider.organizationId, organizationId),
+ ),
+ columns: { providerId: true, issuer: true, oidcConfig: true },
+ });
+ if (!provider) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "SSO provider not found",
+ });
+ }
+ return provider;
+};
+
+export const listSsoProvidersForOrg = async (organizationId: string) => {
+ return db.query.ssoProvider.findMany({
+ where: and(
+ eq(ssoProvider.organizationId, organizationId),
+ isNotNull(ssoProvider.oidcConfig),
+ ),
+ columns: { providerId: true, issuer: true, domain: true },
+ orderBy: [asc(ssoProvider.createdAt)],
+ });
+};
+
+export const getDomainSsoStatus = async (
+ ctx: { session: { activeOrganizationId: string } },
+ domainId: string,
+) => {
+ const domain = await findDomainById(domainId);
+ if (domain.applicationId) {
+ await checkServicePermissionAndAccess(ctx as any, domain.applicationId, {
+ domain: ["read"],
+ });
+ }
+ return { enabled: !!domain.forwardAuthEnabled };
+};
+
+const settingsWhere = (serverId: string | null) =>
+ serverId
+ ? eq(forwardAuthSettings.serverId, serverId)
+ : isNull(forwardAuthSettings.serverId);
+
+export const getForwardAuthSettings = async (serverId: string | null) => {
+ return db.query.forwardAuthSettings.findFirst({
+ where: settingsWhere(serverId),
+ });
+};
+
+export const setForwardAuthSettings = async (input: {
+ organizationId: string;
+ serverId: string | null;
+ authDomain: string;
+ https: boolean;
+ certificateType: "none" | "letsencrypt" | "custom";
+ customCertResolver?: string | null;
+}) => {
+ const baseDomain = deriveBaseDomain(input.authDomain);
+ const existing = await getForwardAuthSettings(input.serverId);
+
+ const values = {
+ authDomain: input.authDomain,
+ baseDomain,
+ https: input.https,
+ certificateType: input.certificateType,
+ customCertResolver: input.customCertResolver ?? null,
+ };
+
+ if (existing) {
+ await db
+ .update(forwardAuthSettings)
+ .set(values)
+ .where(settingsWhere(input.serverId));
+ } else {
+ await db.insert(forwardAuthSettings).values({
+ ...values,
+ serverId: input.serverId,
+ });
+ }
+
+ await manageForwardAuthDomain(input.serverId, {
+ authDomain: input.authDomain,
+ https: input.https,
+ certificateType: input.certificateType,
+ customCertResolver: input.customCertResolver,
+ });
+
+ if (existing?.providerId) {
+ const proxyRunning = await isForwardAuthRunning(
+ input.serverId ?? undefined,
+ );
+ if (proxyRunning) {
+ await deployForwardAuthOnServer({
+ serverId: input.serverId ?? undefined,
+ providerId: existing.providerId,
+ organizationId: input.organizationId,
+ });
+ }
+ }
+
+ return { callbackUrl: forwardAuthCallbackUrl(input.authDomain, input.https) };
+};
+
+export const removeForwardAuthSettings = async (serverId: string | null) => {
+ const existing = await getForwardAuthSettings(serverId);
+ if (!existing) return { ok: true } as const;
+ await removeForwardAuthDomain(serverId);
+ await db.delete(forwardAuthSettings).where(settingsWhere(serverId));
+ return { ok: true } as const;
+};
+
+export const deployForwardAuthOnServer = async (input: {
+ serverId?: string;
+ providerId: string;
+ organizationId: string;
+}) => {
+ const settings = await getForwardAuthSettings(input.serverId ?? null);
+ if (!settings) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message:
+ "Set the authentication domain for this server before deploying the proxy.",
+ });
+ }
+
+ const provider = await findProviderForOrg(
+ input.providerId,
+ input.organizationId,
+ );
+ const oidc = resolveOidcConfig(provider);
+
+ await setupForwardAuth({
+ serverId: input.serverId,
+ oidc,
+ cookieSecret: deriveCookieSecret(
+ `${input.serverId ?? "host"}:${settings.baseDomain}`,
+ ),
+ authDomain: settings.authDomain,
+ baseDomain: settings.baseDomain,
+ authDomainHttps: settings.https,
+ });
+
+ if (settings.providerId !== input.providerId) {
+ await db
+ .update(forwardAuthSettings)
+ .set({ providerId: input.providerId })
+ .where(settingsWhere(input.serverId ?? null));
+ }
+
+ return { ok: true } as const;
+};
+
+const FORWARD_AUTH_CHECK_TIMEOUT_MS = 4000;
+
+const proxyStatus = async (
+ serverId: string | null,
+): Promise<"running" | "stopped" | "unknown"> => {
+ try {
+ const running = await Promise.race([
+ isForwardAuthRunning(serverId ?? undefined),
+ new Promise((_, reject) =>
+ setTimeout(
+ () => reject(new Error("timeout")),
+ FORWARD_AUTH_CHECK_TIMEOUT_MS,
+ ),
+ ),
+ ]);
+ return running ? "running" : "stopped";
+ } catch {
+ return "unknown";
+ }
+};
+
+export const getForwardAuthServerStatus = async (organizationId: string) => {
+ const servers = await db.query.server.findMany({
+ where: and(
+ eq(server.organizationId, organizationId),
+ isNotNull(server.sshKeyId),
+ eq(server.serverType, "deploy"),
+ ),
+ columns: { serverId: true, name: true, ipAddress: true },
+ orderBy: [desc(server.createdAt)],
+ });
+
+ const targets: {
+ serverId: string | null;
+ name: string;
+ ipAddress: string | null;
+ }[] = [
+ ...(IS_CLOUD
+ ? []
+ : [
+ {
+ serverId: null,
+ name: "Dokploy Server (local)",
+ ipAddress: null,
+ },
+ ]),
+ ...servers.map((s) => ({
+ serverId: s.serverId,
+ name: s.name,
+ ipAddress: s.ipAddress,
+ })),
+ ];
+
+ return Promise.all(
+ targets.map(async (t) => {
+ const settings = await getForwardAuthSettings(t.serverId);
+ return {
+ ...t,
+ status: await proxyStatus(t.serverId),
+ authDomain: settings?.authDomain ?? null,
+ https: settings?.https ?? true,
+ certificateType: settings?.certificateType ?? "none",
+ customCertResolver: settings?.customCertResolver ?? null,
+ callbackUrl: settings
+ ? forwardAuthCallbackUrl(settings.authDomain, settings.https)
+ : null,
+ };
+ }),
+ );
+};
+
+export const removeForwardAuthProxy = async (serverId: string | null) => {
+ await removeForwardAuth(serverId ?? undefined);
+ await db
+ .update(forwardAuthSettings)
+ .set({ providerId: null })
+ .where(settingsWhere(serverId));
+ return { ok: true } as const;
+};
+
+const resolveApplicationDomain = async (domainId: string) => {
+ const domain = await findDomainById(domainId);
+ if (!domain.applicationId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "SSO forward-auth is currently only supported on application domains",
+ });
+ }
+ const application = await findApplicationById(domain.applicationId);
+ return { domain, application };
+};
+
+export const assertApplicationDomainAccess = async (
+ ctx: { session: { activeOrganizationId: string } },
+ domainId: string,
+ action: "create" | "delete",
+) => {
+ const domain = await findDomainById(domainId);
+ if (!domain.applicationId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "SSO forward-auth is currently only supported on application domains",
+ });
+ }
+ await checkServicePermissionAndAccess(ctx as any, domain.applicationId, {
+ domain: [action],
+ });
+ return domain;
+};
+
+export const enableForwardAuthOnDomain = async (input: {
+ domainId: string;
+}) => {
+ const { application } = await resolveApplicationDomain(input.domainId);
+ const serverId = application.serverId ?? undefined;
+
+ const settings = await getForwardAuthSettings(serverId ?? null);
+ if (!settings?.providerId) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message:
+ "Deploy the authentication proxy for this server in SSO settings first.",
+ });
+ }
+
+ const proxyRunning = await isForwardAuthRunning(serverId);
+ if (!proxyRunning) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message:
+ "The authentication proxy is not deployed on this server. Deploy it in SSO settings first.",
+ });
+ }
+
+ await updateDomainById(input.domainId, { forwardAuthEnabled: true });
+ const domain = await findDomainById(input.domainId);
+ await manageDomain(application, domain);
+
+ return { ok: true } as const;
+};
+
+export const disableForwardAuthOnDomain = async (input: {
+ domainId: string;
+}) => {
+ const { application, domain } = await resolveApplicationDomain(
+ input.domainId,
+ );
+ const uniqueConfigKey = domain.uniqueConfigKey;
+
+ await updateDomainById(input.domainId, { forwardAuthEnabled: false });
+ const updated = await findDomainById(input.domainId);
+ await manageDomain(application, updated);
+ await removeForwardAuthMiddleware(application, uniqueConfigKey);
+
+ return { ok: true } as const;
+};
diff --git a/packages/server/src/setup/forward-auth-setup.ts b/packages/server/src/setup/forward-auth-setup.ts
new file mode 100644
index 0000000000..d51601875c
--- /dev/null
+++ b/packages/server/src/setup/forward-auth-setup.ts
@@ -0,0 +1,160 @@
+import { createHmac } from "node:crypto";
+import type { CreateServiceOptions } from "dockerode";
+import { getRemoteDocker } from "../utils/servers/remote-docker";
+
+export const FORWARD_AUTH_SERVICE_NAME = "dokploy-forward-auth";
+const FORWARD_AUTH_IMAGE = "quay.io/oauth2-proxy/oauth2-proxy:v7.6.0";
+
+export const FORWARD_AUTH_PORT = 4180;
+
+export interface ForwardAuthOidcConfig {
+ clientId: string;
+ clientSecret: string;
+ issuer: string;
+ scopes?: string[];
+ skipDiscovery?: boolean;
+}
+
+export interface SetupForwardAuthOptions {
+ serverId?: string;
+ oidc: ForwardAuthOidcConfig;
+ cookieSecret: string;
+ authDomain: string;
+ baseDomain: string;
+ authDomainHttps?: boolean;
+ emailDomains?: string[];
+}
+
+export const deriveBaseDomain = (authDomain: string): string => {
+ const labels = authDomain.trim().toLowerCase().split(".").filter(Boolean);
+ const base = labels.length > 2 ? labels.slice(1) : labels;
+ return `.${base.join(".")}`;
+};
+
+export const forwardAuthCallbackUrl = (
+ authDomain: string,
+ https: boolean,
+): string => `${https ? "https" : "http"}://${authDomain}/oauth2/callback`;
+
+export const deriveCookieSecret = (salt: string): string => {
+ const rootSecret = process.env.BETTER_AUTH_SECRET;
+ if (!rootSecret) {
+ throw new Error(
+ "BETTER_AUTH_SECRET is required to derive the forward-auth cookie secret",
+ );
+ }
+ return createHmac("sha256", rootSecret)
+ .update(`forward-auth:${salt}`)
+ .digest("base64");
+};
+
+export const buildForwardAuthEnv = (
+ options: SetupForwardAuthOptions,
+): string[] => {
+ const { oidc, cookieSecret, authDomain, baseDomain, authDomainHttps } =
+ options;
+ const scheme = authDomainHttps ? "https" : "http";
+ const emailDomains =
+ options.emailDomains && options.emailDomains.length > 0
+ ? options.emailDomains
+ : ["*"];
+
+ const env: string[] = [
+ "OAUTH2_PROXY_PROVIDER=oidc",
+ `OAUTH2_PROXY_OIDC_ISSUER_URL=${oidc.issuer}`,
+ `OAUTH2_PROXY_CLIENT_ID=${oidc.clientId}`,
+ `OAUTH2_PROXY_CLIENT_SECRET=${oidc.clientSecret}`,
+ `OAUTH2_PROXY_COOKIE_SECRET=${cookieSecret}`,
+ `OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:${FORWARD_AUTH_PORT}`,
+ "OAUTH2_PROXY_REVERSE_PROXY=true",
+ "OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true",
+ "OAUTH2_PROXY_SET_XAUTHREQUEST=true",
+ "OAUTH2_PROXY_UPSTREAMS=static://202",
+ `OAUTH2_PROXY_REDIRECT_URL=${scheme}://${authDomain}/oauth2/callback`,
+ `OAUTH2_PROXY_COOKIE_DOMAINS=${baseDomain}`,
+ `OAUTH2_PROXY_WHITELIST_DOMAINS=${baseDomain}`,
+ `OAUTH2_PROXY_COOKIE_SECURE=${authDomainHttps ? "true" : "false"}`,
+ "OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true",
+ `OAUTH2_PROXY_EMAIL_DOMAINS=${emailDomains.join(",")}`,
+ ];
+
+ const scopes = oidc.scopes?.length
+ ? oidc.scopes
+ : ["openid", "email", "profile"];
+ env.push(`OAUTH2_PROXY_SCOPE=${scopes.join(" ")}`);
+
+ if (oidc.skipDiscovery) {
+ env.push("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
+ }
+
+ return env;
+};
+
+export const setupForwardAuth = async (options: SetupForwardAuthOptions) => {
+ const { serverId } = options;
+ const docker = await getRemoteDocker(serverId);
+
+ const settings: CreateServiceOptions = {
+ Name: FORWARD_AUTH_SERVICE_NAME,
+ TaskTemplate: {
+ ContainerSpec: {
+ Image: FORWARD_AUTH_IMAGE,
+ Env: buildForwardAuthEnv(options),
+ },
+ Networks: [{ Target: "dokploy-network" }],
+ Placement: {
+ Constraints: ["node.role==manager"],
+ },
+ },
+ Mode: {
+ Replicated: {
+ Replicas: 1,
+ },
+ },
+ };
+
+ try {
+ const service = docker.getService(FORWARD_AUTH_SERVICE_NAME);
+ const inspect = await service.inspect();
+ await service.update({
+ version: Number.parseInt(inspect.Version.Index),
+ ...settings,
+ TaskTemplate: {
+ ...settings.TaskTemplate,
+ ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1,
+ },
+ });
+ console.log("Forward Auth Updated ✅");
+ } catch (_) {
+ try {
+ await docker.createService(settings);
+ console.log("Forward Auth Started ✅");
+ } catch (error: any) {
+ if (error?.statusCode !== 409) {
+ throw error;
+ }
+ console.log("Forward Auth service already exists, continuing...");
+ }
+ }
+};
+
+export const removeForwardAuth = async (serverId?: string) => {
+ const docker = await getRemoteDocker(serverId);
+ try {
+ const service = docker.getService(FORWARD_AUTH_SERVICE_NAME);
+ await service.remove();
+ console.log("Forward Auth Removed ✅");
+ } catch {}
+};
+
+export const isForwardAuthRunning = async (
+ serverId?: string,
+): Promise => {
+ const docker = await getRemoteDocker(serverId);
+ try {
+ await docker.getService(FORWARD_AUTH_SERVICE_NAME).inspect();
+ return true;
+ } catch {
+ return false;
+ }
+};
diff --git a/packages/server/src/utils/traefik/domain.ts b/packages/server/src/utils/traefik/domain.ts
index 596758b332..d35473a543 100644
--- a/packages/server/src/utils/traefik/domain.ts
+++ b/packages/server/src/utils/traefik/domain.ts
@@ -10,6 +10,11 @@ import {
writeTraefikConfigRemote,
} from "./application";
import type { FileConfig, HttpRouter } from "./file-types";
+import {
+ createForwardAuthMiddleware,
+ forwardAuthMiddlewareName,
+ removeForwardAuthMiddleware,
+} from "./forward-auth";
import { createPathMiddlewares, removePathMiddlewares } from "./middleware";
export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
@@ -48,6 +53,10 @@ export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
config.http.services[serviceName] = createServiceConfig(appName, domain);
await createPathMiddlewares(app, domain);
+ // SSO forward-auth: writes the per-app forwardAuth + errors middlewares (the
+ // /oauth2/* router lives on the central auth domain, not here). No-op unless
+ // the domain links a provider and the org has an auth domain configured.
+ await createForwardAuthMiddleware(app, domain);
if (app.serverId) {
await writeTraefikConfigRemote(config, appName, app.serverId);
@@ -84,6 +93,7 @@ export const removeDomain = async (
}
await removePathMiddlewares(application, uniqueKey);
+ await removeForwardAuthMiddleware(application, uniqueKey);
// verify if is the last router if so we delete the router
if (
@@ -184,6 +194,16 @@ export const createRouterConfig = async (
routerConfig.middlewares?.push(middlewareName);
}
+ // Enterprise SSO forward-auth gate. Placed before custom middlewares so
+ // authentication runs first. No-op unless the domain links a provider.
+ // The -errors middleware must come first so a 401 from the auth check is
+ // rewritten to a 302 redirect to the login page.
+ if (domain.forwardAuthEnabled) {
+ const name = forwardAuthMiddlewareName(appName, uniqueConfigKey);
+ routerConfig.middlewares?.push(`${name}-errors`);
+ routerConfig.middlewares?.push(name);
+ }
+
// custom middlewares from domain
if (domain.middlewares && domain.middlewares.length > 0) {
routerConfig.middlewares?.push(...domain.middlewares);
diff --git a/packages/server/src/utils/traefik/file-types.ts b/packages/server/src/utils/traefik/file-types.ts
index e761cb5128..f9149c2cc5 100644
--- a/packages/server/src/utils/traefik/file-types.ts
+++ b/packages/server/src/utils/traefik/file-types.ts
@@ -652,6 +652,13 @@ export interface ErrorsMiddleware {
* The URL for the error page (hosted by service). You can use {status} in the query, that will be replaced by the received status code.
*/
query?: string;
+ /**
+ * Rewrites the returning status code, mapping the original status to a new one
+ * (e.g. { "401": 302 } so the browser follows the redirect to the login page).
+ */
+ statusRewrites?: {
+ [k: string]: number;
+ };
}
/**
* The ForwardAuth middleware delegate the authentication to an external service. If the service response code is 2XX, access is granted and the original request is performed. Otherwise, the response from the authentication server is returned.
diff --git a/packages/server/src/utils/traefik/forward-auth.ts b/packages/server/src/utils/traefik/forward-auth.ts
new file mode 100644
index 0000000000..3d6c207e34
--- /dev/null
+++ b/packages/server/src/utils/traefik/forward-auth.ts
@@ -0,0 +1,204 @@
+import { db } from "@dokploy/server/db";
+import { forwardAuthSettings } from "@dokploy/server/db/schema";
+import type { Domain } from "@dokploy/server/services/domain";
+import {
+ FORWARD_AUTH_PORT,
+ FORWARD_AUTH_SERVICE_NAME,
+} from "@dokploy/server/setup/forward-auth-setup";
+import { eq, isNull } from "drizzle-orm";
+import type { ApplicationNested } from "../builders";
+import {
+ removeTraefikConfig,
+ removeTraefikConfigRemote,
+ writeTraefikConfig,
+ writeTraefikConfigRemote,
+} from "./application";
+import type { FileConfig } from "./file-types";
+import {
+ loadMiddlewares,
+ loadRemoteMiddlewares,
+ writeMiddleware,
+} from "./middleware";
+
+export interface AuthDomainConfig {
+ authDomain: string;
+ https: boolean;
+ certificateType: "none" | "letsencrypt" | "custom";
+ customCertResolver?: string | null;
+}
+
+const TRAEFIK_SERVICE = "forward-auth-proxy";
+
+export const forwardAuthMiddlewareName = (
+ appName: string,
+ uniqueConfigKey: number,
+): string => `forward-auth-${appName}-${uniqueConfigKey}`;
+
+const proxyUrl = () =>
+ `http://${FORWARD_AUTH_SERVICE_NAME}:${FORWARD_AUTH_PORT}`;
+
+const loadOrEmptyMiddlewares = async (
+ serverId: string | null,
+): Promise => {
+ try {
+ return serverId
+ ? await loadRemoteMiddlewares(serverId)
+ : loadMiddlewares();
+ } catch {
+ return { http: { middlewares: {} } };
+ }
+};
+
+const persistMiddlewares = async (
+ config: FileConfig,
+ serverId: string | null,
+) => {
+ if (serverId) {
+ await writeTraefikConfigRemote(config, "middlewares", serverId);
+ } else {
+ writeMiddleware(config);
+ }
+};
+
+const loadAuthGateDomain = async (serverId: string | null) => {
+ return db.query.forwardAuthSettings.findFirst({
+ where: serverId
+ ? eq(forwardAuthSettings.serverId, serverId)
+ : isNull(forwardAuthSettings.serverId),
+ columns: { authDomain: true, https: true },
+ });
+};
+
+export const createForwardAuthMiddleware = async (
+ app: ApplicationNested,
+ domain: Domain,
+) => {
+ if (!domain.forwardAuthEnabled) {
+ return;
+ }
+
+ const authGate = await loadAuthGateDomain(app.serverId ?? null);
+ if (!authGate) {
+ return;
+ }
+ const authDomain = authGate.authDomain;
+ const authDomainHttps = authGate.https;
+
+ const { appName, serverId } = app;
+ const config = await loadOrEmptyMiddlewares(serverId);
+
+ config.http = config.http || {};
+ config.http.middlewares = config.http.middlewares || {};
+
+ const name = forwardAuthMiddlewareName(appName, domain.uniqueConfigKey);
+ const scheme = authDomainHttps ? "https" : "http";
+
+ config.http.middlewares[name] = {
+ forwardAuth: {
+ address: `${scheme}://${authDomain}/oauth2/auth`,
+ trustForwardHeader: true,
+ authResponseHeaders: [
+ "X-Auth-Request-User",
+ "X-Auth-Request-Email",
+ "X-Auth-Request-Preferred-Username",
+ "Authorization",
+ ],
+ },
+ };
+
+ config.http.middlewares[`${name}-errors`] = {
+ errors: {
+ status: ["401-403"],
+ service: TRAEFIK_SERVICE,
+ query: "/oauth2/sign_in?rd={url}",
+ statusRewrites: { "401": 302 },
+ },
+ };
+
+ await persistMiddlewares(config, serverId);
+};
+
+export const removeForwardAuthMiddleware = async (
+ app: ApplicationNested,
+ uniqueConfigKey: number,
+) => {
+ const { appName, serverId } = app;
+ let config: FileConfig;
+ try {
+ config = serverId
+ ? await loadRemoteMiddlewares(serverId)
+ : loadMiddlewares();
+ } catch {
+ return;
+ }
+
+ const name = forwardAuthMiddlewareName(appName, uniqueConfigKey);
+ let changed = false;
+ for (const key of [name, `${name}-errors`]) {
+ if (config.http?.middlewares?.[key]) {
+ delete config.http.middlewares[key];
+ changed = true;
+ }
+ }
+ if (changed) {
+ await persistMiddlewares(config, serverId);
+ }
+};
+
+export const buildAuthDomainRouter = (cfg: AuthDomainConfig): FileConfig => {
+ const entry = cfg.https ? "websecure" : "web";
+ const oauthRouter: NonNullable<
+ NonNullable["routers"]
+ >[string] = {
+ rule: `Host(\`${cfg.authDomain}\`) && PathPrefix(\`/oauth2/\`)`,
+ service: TRAEFIK_SERVICE,
+ entryPoints: [entry],
+ priority: 1000,
+ };
+
+ if (cfg.https) {
+ if (cfg.certificateType === "letsencrypt") {
+ oauthRouter.tls = { certResolver: "letsencrypt" };
+ } else if (cfg.certificateType === "custom" && cfg.customCertResolver) {
+ oauthRouter.tls = { certResolver: cfg.customCertResolver };
+ } else {
+ oauthRouter.tls = {};
+ }
+ }
+
+ return {
+ http: {
+ routers: { "forward-auth-oauth": oauthRouter },
+ services: {
+ [TRAEFIK_SERVICE]: {
+ loadBalancer: {
+ servers: [{ url: proxyUrl() }],
+ passHostHeader: true,
+ },
+ },
+ },
+ },
+ };
+};
+
+export const authDomainConfigName = "forward-auth-domain";
+
+export const manageForwardAuthDomain = async (
+ serverId: string | null,
+ cfg: AuthDomainConfig,
+) => {
+ const config = buildAuthDomainRouter(cfg);
+ if (serverId) {
+ await writeTraefikConfigRemote(config, authDomainConfigName, serverId);
+ } else {
+ writeTraefikConfig(config, authDomainConfigName);
+ }
+};
+
+export const removeForwardAuthDomain = async (serverId: string | null) => {
+ if (serverId) {
+ await removeTraefikConfigRemote(authDomainConfigName, serverId);
+ } else {
+ await removeTraefikConfig(authDomainConfigName);
+ }
+};