Merge pull request #4555 from Dokploy/feat/forward-auth-sso

Feat/forward auth sso
This commit is contained in:
Mauricio Siu
2026-06-06 13:58:05 -06:00
committed by GitHub
27 changed files with 18883 additions and 10 deletions
@@ -34,6 +34,7 @@ describe("Host rule format regression tests", () => {
stripPath: false,
customEntrypoint: null,
middlewares: null,
forwardAuthEnabled: false,
};
describe("Host rule format validation", () => {
@@ -23,6 +23,7 @@ describe("createDomainLabels", () => {
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
};
it("should create basic labels for web entrypoint", async () => {
@@ -0,0 +1,233 @@
import type { ApplicationNested, Domain } from "@dokploy/server";
import {
buildForwardAuthEnv,
createRouterConfig,
deriveBaseDomain,
deriveCookieSecret,
forwardAuthCallbackUrl,
forwardAuthMiddlewareName,
} from "@dokploy/server";
import { beforeAll, describe, expect, test } from "vitest";
const app = {
appName: "my-app",
redirects: [],
security: [],
} as unknown as ApplicationNested;
const baseDomain: Domain = {
applicationId: "app-1",
certificateType: "none",
createdAt: "",
domainId: "domain-1",
host: "app.example.com",
https: false,
path: null,
port: 3000,
customEntrypoint: null,
serviceName: "",
composeId: "",
customCertResolver: null,
domainType: "application",
uniqueConfigKey: 7,
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
};
describe("forwardAuthMiddlewareName", () => {
test("is stable and unique per app + uniqueConfigKey", () => {
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
"forward-auth-my-app-7",
);
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
forwardAuthMiddlewareName("my-app", 7),
);
expect(forwardAuthMiddlewareName("my-app", 7)).not.toBe(
forwardAuthMiddlewareName("my-app", 8),
);
});
});
describe("createRouterConfig forward-auth wiring", () => {
test("does NOT add forward-auth middleware when no provider is linked", async () => {
const config = await createRouterConfig(app, baseDomain, "websecure");
expect(config.middlewares).not.toContain(
forwardAuthMiddlewareName("my-app", 7),
);
});
test("adds forward-auth middleware when a provider is linked", async () => {
const domain: Domain = {
...baseDomain,
forwardAuthEnabled: true,
};
const config = await createRouterConfig(app, domain, "websecure");
expect(config.middlewares).toContain(
forwardAuthMiddlewareName("my-app", 7),
);
});
test("forward-auth runs before custom domain middlewares", async () => {
const domain: Domain = {
...baseDomain,
forwardAuthEnabled: true,
middlewares: ["rate-limit@file"],
};
const config = await createRouterConfig(app, domain, "websecure");
const forwardAuthIdx = config.middlewares?.indexOf(
forwardAuthMiddlewareName("my-app", 7),
);
const customIdx = config.middlewares?.indexOf("rate-limit@file");
expect(forwardAuthIdx).toBeGreaterThanOrEqual(0);
expect(customIdx).toBeGreaterThan(forwardAuthIdx as number);
});
test("redirect-only web router does not get the forward-auth middleware", async () => {
const domain: Domain = {
...baseDomain,
https: true,
forwardAuthEnabled: true,
};
const config = await createRouterConfig(app, domain, "web");
expect(config.middlewares).toContain("redirect-to-https");
expect(config.middlewares).not.toContain(
forwardAuthMiddlewareName("my-app", 7),
);
});
});
describe("buildForwardAuthEnv", () => {
const baseOptions = {
oidc: {
clientId: "client-123",
clientSecret: "secret-xyz",
issuer: "https://idp.example.com",
},
cookieSecret: "cookie-secret-value",
authDomain: "auth.acme.com",
baseDomain: ".acme.com",
authDomainHttps: true,
};
test("emits the required oauth2-proxy OIDC env vars", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain("OAUTH2_PROXY_PROVIDER=oidc");
expect(env).toContain(
"OAUTH2_PROXY_OIDC_ISSUER_URL=https://idp.example.com",
);
expect(env).toContain("OAUTH2_PROXY_CLIENT_ID=client-123");
expect(env).toContain("OAUTH2_PROXY_CLIENT_SECRET=secret-xyz");
expect(env).toContain("OAUTH2_PROXY_COOKIE_SECRET=cookie-secret-value");
expect(env).toContain("OAUTH2_PROXY_REVERSE_PROXY=true");
expect(env).toContain("OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180");
});
test("uses the central auth domain for the single fixed callback", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain(
"OAUTH2_PROXY_REDIRECT_URL=https://auth.acme.com/oauth2/callback",
);
});
test("shares cookie + whitelist on the base domain (no per-app redeploy)", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain("OAUTH2_PROXY_COOKIE_DOMAINS=.acme.com");
expect(env).toContain("OAUTH2_PROXY_WHITELIST_DOMAINS=.acme.com");
});
test("matches cookie Secure flag and callback scheme to https setting", () => {
const https = buildForwardAuthEnv(baseOptions);
expect(https).toContain("OAUTH2_PROXY_COOKIE_SECURE=true");
const http = buildForwardAuthEnv({
...baseOptions,
authDomainHttps: false,
});
expect(http).toContain("OAUTH2_PROXY_COOKIE_SECURE=false");
expect(http).toContain(
"OAUTH2_PROXY_REDIRECT_URL=http://auth.acme.com/oauth2/callback",
);
});
test("allows unverified emails so OIDC providers don't 500 the callback", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain(
"OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true",
);
});
test("defaults to any authenticated user and standard scopes", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=*");
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid email profile");
});
test("honors custom scopes and email domains", () => {
const env = buildForwardAuthEnv({
...baseOptions,
oidc: { ...baseOptions.oidc, scopes: ["openid", "groups"] },
emailDomains: ["acme.com", "corp.com"],
});
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid groups");
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=acme.com,corp.com");
});
test("sets skip-discovery flag only when requested", () => {
const withoutSkip = buildForwardAuthEnv(baseOptions);
expect(withoutSkip).not.toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
const withSkip = buildForwardAuthEnv({
...baseOptions,
oidc: { ...baseOptions.oidc, skipDiscovery: true },
});
expect(withSkip).toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
});
});
describe("deriveBaseDomain", () => {
test("strips the auth subdomain to the shared base", () => {
expect(deriveBaseDomain("auth.acme.com")).toBe(".acme.com");
expect(deriveBaseDomain("sso.apps.acme.com")).toBe(".apps.acme.com");
});
test("keeps a two-label apex as the base", () => {
expect(deriveBaseDomain("acme.com")).toBe(".acme.com");
});
});
describe("forwardAuthCallbackUrl", () => {
test("builds the single IdP callback per scheme", () => {
expect(forwardAuthCallbackUrl("auth.acme.com", true)).toBe(
"https://auth.acme.com/oauth2/callback",
);
expect(forwardAuthCallbackUrl("auth.acme.com", false)).toBe(
"http://auth.acme.com/oauth2/callback",
);
});
});
describe("deriveCookieSecret", () => {
beforeAll(() => {
process.env.BETTER_AUTH_SECRET = "test-root-secret";
});
test("is deterministic for the same salt (survives service updates)", () => {
expect(deriveCookieSecret(".acme.com")).toBe(
deriveCookieSecret(".acme.com"),
);
});
test("differs per salt", () => {
expect(deriveCookieSecret(".acme.com")).not.toBe(
deriveCookieSecret(".other.com"),
);
});
test("produces a 32-byte base64 secret (oauth2-proxy requirement)", () => {
const secret = deriveCookieSecret(".acme.com");
expect(Buffer.from(secret, "base64")).toHaveLength(32);
});
});
@@ -148,6 +148,7 @@ const baseDomain: Domain = {
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
};
const baseRedirect: Redirect = {
@@ -0,0 +1,147 @@
import { ShieldCheck } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
interface Props {
domainId: string;
applicationId: string;
}
export const HandleForwardAuth = ({ domainId, applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: haveValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const utils = api.useUtils();
const { data: status } = api.forwardAuth.status.useQuery(
{ domainId },
{ enabled: isOpen },
);
const { mutateAsync: enable, isPending: isEnabling } =
api.forwardAuth.enable.useMutation();
const { mutateAsync: disable, isPending: isDisabling } =
api.forwardAuth.disable.useMutation();
if (!haveValidLicense) {
return null;
}
const isEnabled = !!status?.enabled;
const isPending = isEnabling || isDisabling;
const refresh = async () => {
await utils.forwardAuth.status.invalidate({ domainId });
await utils.domain.byApplicationId.invalidate({ applicationId });
await utils.application.readTraefikConfig.invalidate({ applicationId });
};
const handleToggle = async (next: boolean) => {
try {
if (next) {
await enable({ domainId });
toast.success("SSO authentication enabled for this domain");
} else {
await disable({ domainId });
toast.success("SSO authentication disabled for this domain");
}
await refresh();
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Error updating SSO authentication",
);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group hover:bg-emerald-500/10"
title="SSO authentication"
>
<ShieldCheck
className={`size-4 ${
isEnabled
? "text-emerald-500"
: "text-primary group-hover:text-emerald-500"
}`}
/>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>SSO Authentication</DialogTitle>
<DialogDescription>
Require visitors to authenticate against your identity provider
before reaching this application.
</DialogDescription>
</DialogHeader>
<AlertBlock type="warning">
<div className="flex flex-col gap-1">
<span className="font-medium">Requirements</span>
<ol className="list-decimal pl-4 text-sm">
<li>
The authentication proxy container must be deployed and running
on this app's server. Configure it under{" "}
<span className="font-medium">
Settings SSO Application Authentication
</span>
.
</li>
<li>
This domain must share the same base domain as the
authentication domain (e.g. <code>app.acme.com</code> and{" "}
<code>auth.acme.com</code>).
</li>
</ol>
</div>
</AlertBlock>
<div className="flex items-center justify-between rounded-lg border p-4 mt-2">
<div className="flex flex-col">
<span className="text-sm font-medium">
Protect this domain with SSO
</span>
<span className="text-xs text-muted-foreground">
{isEnabled
? "Visitors must log in via your identity provider."
: "The domain is publicly accessible."}
</span>
</div>
<Switch
checked={isEnabled}
disabled={isPending}
onCheckedChange={handleToggle}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -62,6 +62,7 @@ import { api } from "@/utils/api";
import { createColumns } from "./columns";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
import { HandleForwardAuth } from "./handle-forward-auth";
export type ValidationState = {
isLoading: boolean;
@@ -453,6 +454,12 @@ export const ShowDomains = ({ id, type }: Props) => {
</Button>
</AddDomain>
)}
{canCreateDomain && type === "application" && (
<HandleForwardAuth
domainId={item.domainId}
applicationId={id}
/>
)}
{canDeleteDomain && (
<DialogAction
title="Delete Domain"
@@ -0,0 +1,482 @@
"use client";
import {
Copy,
Dices,
HelpCircle,
Loader2,
ShieldCheck,
ShieldOff,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { DnsHelperModal } from "@/components/dashboard/application/domains/dns-helper-modal";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
type ServerStatus = "running" | "stopped" | "unknown";
type Target = { serverId: string | null; name: string };
type CertType = "none" | "letsencrypt" | "custom";
type DomainForm = {
host: string;
https: boolean;
certificateType: CertType;
customCertResolver: string;
};
export const ForwardAuthServers = () => {
const utils = api.useUtils();
const [enabled, setEnabled] = useState(false);
const [deployTarget, setDeployTarget] = useState<Target | null>(null);
const [selectedProviderId, setSelectedProviderId] = useState("");
const [forms, setForms] = useState<Record<string, DomainForm>>({});
useEffect(() => {
const id = setTimeout(() => setEnabled(true), 0);
return () => clearTimeout(id);
}, []);
const { data: hostIp } = api.settings.getIp.useQuery();
const { data: servers, isPending } = api.forwardAuth.serverStatus.useQuery(
undefined,
{ enabled, refetchOnWindowFocus: false, staleTime: 30_000 },
);
const { data: providers } = api.forwardAuth.listProviders.useQuery(
undefined,
{
enabled: !!deployTarget,
},
);
const { mutateAsync: saveAuthDomain, isPending: isSaving } =
api.forwardAuth.setAuthDomain.useMutation();
const { mutateAsync: deployOnServer, isPending: isDeploying } =
api.forwardAuth.deployOnServer.useMutation();
const { mutateAsync: removeOnServer, isPending: isRemoving } =
api.forwardAuth.removeOnServer.useMutation();
const { mutateAsync: generateDomain, isPending: isGenerating } =
api.domain.generateDomain.useMutation();
const keyOf = (serverId: string | null) => serverId ?? "local";
useEffect(() => {
if (!servers) return;
setForms((prev) => {
const next = { ...prev };
for (const srv of servers) {
const key = srv.serverId ?? "local";
if (next[key] === undefined) {
next[key] = {
host: srv.authDomain ?? "",
https: srv.https ?? true,
certificateType: (srv.certificateType ?? "letsencrypt") as CertType,
customCertResolver: srv.customCertResolver ?? "",
};
}
}
return next;
});
}, [servers]);
const hasProviders = (providers?.length ?? 0) > 0;
const patchForm = (serverId: string | null, patch: Partial<DomainForm>) =>
setForms((p) => {
const key = keyOf(serverId);
const current: DomainForm = p[key] ?? {
host: "",
https: true,
certificateType: "letsencrypt",
customCertResolver: "",
};
return { ...p, [key]: { ...current, ...patch } };
});
const handleSaveDomain = async (serverId: string | null) => {
const f = forms[keyOf(serverId)];
if (!f?.host.trim()) {
toast.error("Enter an auth domain first");
return false;
}
if (f.certificateType === "custom" && !f.customCertResolver.trim()) {
toast.error("Enter the custom certificate resolver");
return false;
}
try {
await saveAuthDomain({
serverId,
authDomain: f.host.trim(),
https: f.https,
certificateType: f.certificateType,
customCertResolver: f.customCertResolver.trim() || undefined,
});
return true;
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error saving auth domain",
);
return false;
}
};
const handleDeploy = async () => {
if (!deployTarget || !selectedProviderId) {
toast.error("Select an SSO provider first");
return;
}
try {
const saved = await handleSaveDomain(deployTarget.serverId);
if (!saved) return;
await deployOnServer({
serverId: deployTarget.serverId,
providerId: selectedProviderId,
});
await utils.forwardAuth.serverStatus.invalidate();
toast.success("Authentication proxy deployed");
setDeployTarget(null);
setSelectedProviderId("");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error deploying proxy",
);
}
};
const handleRemove = async (serverId: string | null) => {
try {
await removeOnServer({ serverId });
await utils.forwardAuth.serverStatus.invalidate();
toast.success("Authentication proxy removed");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error removing proxy",
);
}
};
const handleGenerateDomain = async (serverId: string | null) => {
try {
const host = await generateDomain({
appName: "auth",
serverId: serverId ?? undefined,
});
patchForm(serverId, { host, https: false, certificateType: "none" });
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error generating domain",
);
}
};
const statusBadge = (status: ServerStatus) => {
if (status === "running") {
return (
<Badge
variant="outline"
className="border-emerald-500/40 text-emerald-500"
>
<ShieldCheck className="mr-1 size-3" />
Running
</Badge>
);
}
if (status === "stopped") {
return (
<Badge variant="secondary">
<ShieldOff className="mr-1 size-3" />
Not deployed
</Badge>
);
}
return (
<Badge
variant="outline"
className="border-amber-500/40 text-amber-500"
title="Could not reach this server in time"
>
<HelpCircle className="mr-1 size-3" />
Unknown
</Badge>
);
};
return (
<Card className="bg-transparent">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<ShieldCheck className="size-5" />
Application Authentication
</CardTitle>
<CardDescription>
Each server has its own authentication domain and proxy. Set an auth
domain (e.g. auth.acme.com) per server, register its callback URL once
in your identity provider, then deploy the proxy. Apps on that server
under the same base domain are then one click to protect.
<span className="mt-2 block font-medium">
Only OIDC providers are supported SAML is not compatible with the
forward-auth proxy.
</span>
</CardDescription>
</CardHeader>
<CardContent>
{isPending || !enabled ? (
<div className="flex items-center gap-2 justify-center py-6 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<span className="text-sm">Checking servers...</span>
</div>
) : (
<div className="flex flex-col gap-4">
{servers?.map((srv) => {
const key = keyOf(srv.serverId);
const f = forms[key];
return (
<div
key={key}
className="flex flex-col gap-3 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{srv.name}</span>
<div className="flex items-center gap-2">
{statusBadge(srv.status)}
{srv.status === "running" && (
<DialogAction
title="Remove authentication proxy"
description="Domains on this server protected with SSO will stop requiring authentication until re-enabled. Continue?"
type="destructive"
onClick={() => handleRemove(srv.serverId)}
>
<Button
variant="ghost"
size="sm"
isLoading={isRemoving}
>
Remove
</Button>
</DialogAction>
)}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<span className="text-xs font-medium">Auth domain</span>
<div className="flex gap-2">
<Input
placeholder="auth.acme.com"
value={f?.host ?? ""}
onChange={(e) =>
patchForm(srv.serverId, { host: e.target.value })
}
className="font-mono text-sm"
/>
{f?.host && !f.host.includes("sslip.io") && (
<DnsHelperModal
domain={{
host: f.host,
https: f.https,
}}
serverIp={
srv.ipAddress ?? hostIp?.toString() ?? undefined
}
/>
)}
<Button
type="button"
variant="secondary"
size="icon"
isLoading={isGenerating}
title="Generate sslip.io domain"
onClick={() => handleGenerateDomain(srv.serverId)}
>
<Dices className="size-4 text-muted-foreground" />
</Button>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs font-medium">
Certificate provider
</span>
<Select
value={f?.https ? f.certificateType : "none"}
onValueChange={(v) =>
patchForm(srv.serverId, {
certificateType: v as CertType,
https: v !== "none",
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select a certificate provider" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (HTTP)</SelectItem>
<SelectItem value="letsencrypt">
Let's Encrypt
</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{f?.certificateType === "custom" && f?.https && (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium">
Custom certificate resolver
</span>
<Input
placeholder="Enter your custom certificate resolver"
value={f?.customCertResolver ?? ""}
onChange={(e) =>
patchForm(srv.serverId, {
customCertResolver: e.target.value,
})
}
/>
</div>
)}
<div className="flex justify-end">
<Button
size="sm"
disabled={!f?.host?.trim()}
onClick={() =>
setDeployTarget({
serverId: srv.serverId,
name: srv.name,
})
}
>
Deploy
</Button>
</div>
{srv.callbackUrl && (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium">
Callback URL (register once in your IdP)
</span>
<div className="flex gap-2">
<Input
readOnly
value={srv.callbackUrl}
className="font-mono text-xs"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
navigator.clipboard.writeText(
srv.callbackUrl as string,
);
toast.success("Callback URL copied");
}}
>
<Copy className="size-4" />
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</CardContent>
<Dialog
open={!!deployTarget}
onOpenChange={(open) => {
if (!open) {
setDeployTarget(null);
setSelectedProviderId("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Deploy authentication proxy</DialogTitle>
<DialogDescription>
Deploy the SSO proxy on{" "}
<span className="font-medium">{deployTarget?.name}</span> using an
OIDC provider.
</DialogDescription>
</DialogHeader>
{!hasProviders && (
<AlertBlock type="warning">
No SSO providers configured. Add an OIDC provider above first.
</AlertBlock>
)}
<div className="flex flex-col gap-2 py-2">
<span className="text-sm font-medium">Identity provider</span>
<Select
value={selectedProviderId}
onValueChange={setSelectedProviderId}
disabled={!hasProviders}
>
<SelectTrigger>
<SelectValue placeholder="Select an SSO provider">
{selectedProviderId || ""}
</SelectValue>
</SelectTrigger>
<SelectContent>
{providers?.map((provider) => (
<SelectItem
key={provider.providerId}
value={provider.providerId}
>
<div className="flex flex-col">
<span className="font-medium">{provider.providerId}</span>
<span className="text-xs text-muted-foreground">
{provider.issuer}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
isLoading={isSaving || isDeploying}
disabled={!hasProviders || !selectedProviderId}
onClick={handleDeploy}
>
Deploy
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
};
@@ -0,0 +1,16 @@
CREATE TABLE "forward_auth_settings" (
"forwardAuthSettingsId" text PRIMARY KEY NOT NULL,
"authDomain" text NOT NULL,
"baseDomain" text NOT NULL,
"https" boolean DEFAULT true NOT NULL,
"certificateType" "certificateType" DEFAULT 'letsencrypt' NOT NULL,
"customCertResolver" text,
"providerId" text,
"serverId" text,
"createdAt" text NOT NULL,
CONSTRAINT "forward_auth_settings_serverId_unique" UNIQUE("serverId")
);
--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "forwardAuthEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "forward_auth_settings" ADD CONSTRAINT "forward_auth_settings_providerId_sso_provider_provider_id_fk" FOREIGN KEY ("providerId") REFERENCES "public"."sso_provider"("provider_id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "forward_auth_settings" ADD CONSTRAINT "forward_auth_settings_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE "sso_provider" DROP CONSTRAINT "sso_provider_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
+1 -1
View File
@@ -8329,4 +8329,4 @@
"schemas": {},
"tables": {}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -1191,6 +1191,20 @@
"when": 1780127552074,
"tag": "0169_parched_johnny_storm",
"breakpoints": true
},
{
"idx": 170,
"version": "7",
"when": 1780739532982,
"tag": "0170_amusing_spot",
"breakpoints": true
},
{
"idx": 171,
"version": "7",
"when": 1780775037209,
"tag": "0171_lucky_echo",
"breakpoints": true
}
]
}
@@ -7,6 +7,7 @@ import { ToggleEnforceSSO } from "@/components/dashboard/settings/servers/action
import { ToggleRemoteServersOnly } from "@/components/dashboard/settings/servers/actions/toggle-remote-servers-only";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
import { ForwardAuthServers } from "@/components/proprietary/sso/forward-auth-servers";
import { SSOSettings } from "@/components/proprietary/sso/sso-settings";
import {
Card,
@@ -41,6 +42,20 @@ const Page = ({ isCloud }: Props) => {
</div>
</div>
</Card>
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<EnterpriseFeatureGate
lockedProps={{
title: "Application Authentication",
description:
"Protect deployed applications behind an OIDC SSO gate (oauth2-proxy). Part of Dokploy Enterprise.",
ctaLabel: "Go to License",
}}
>
<ForwardAuthServers />
</EnterpriseFeatureGate>
</div>
</Card>
{!isCloud && (
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
+2
View File
@@ -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,
@@ -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;
}),
});
@@ -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 });
+3
View File
@@ -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());
@@ -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),
});
+1
View File
@@ -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";
+1 -1
View File
@@ -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",
}),
+3
View File
@@ -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";
@@ -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<never>((_, 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;
};
@@ -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<boolean> => {
const docker = await getRemoteDocker(serverId);
try {
await docker.getService(FORWARD_AUTH_SERVICE_NAME).inspect();
return true;
} catch {
return false;
}
};
@@ -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);
@@ -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.
@@ -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<FileConfig> => {
try {
return serverId
? await loadRemoteMiddlewares(serverId)
: loadMiddlewares<FileConfig>();
} 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<FileConfig>();
} 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<FileConfig["http"]>["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);
}
};