mirror of
https://github.com/dokploy/dokploy.git
synced 2026-06-13 19:09:49 +00:00
feat: implement forward authentication settings and UI components
- Added a new `forward_auth_settings` table to manage authentication domains and their configurations. - Introduced UI components for handling forward authentication, including enabling/disabling SSO for domains and selecting SSO providers. - Updated existing tests to include validation for the new `forwardAuthProviderId` field in domain configurations. - Enhanced the dashboard to integrate forward authentication management, allowing users to configure SSO settings directly from the application interface. This update improves the flexibility and security of application authentication by allowing integration with various identity providers.
This commit is contained in:
@@ -34,6 +34,7 @@ describe("Host rule format regression tests", () => {
|
||||
stripPath: false,
|
||||
customEntrypoint: null,
|
||||
middlewares: null,
|
||||
forwardAuthProviderId: null,
|
||||
};
|
||||
|
||||
describe("Host rule format validation", () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ describe("createDomainLabels", () => {
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthProviderId: null,
|
||||
};
|
||||
|
||||
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,
|
||||
forwardAuthProviderId: null,
|
||||
};
|
||||
|
||||
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,
|
||||
forwardAuthProviderId: "provider-abc",
|
||||
};
|
||||
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,
|
||||
forwardAuthProviderId: "provider-abc",
|
||||
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,
|
||||
forwardAuthProviderId: "provider-abc",
|
||||
};
|
||||
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,
|
||||
forwardAuthProviderId: null,
|
||||
};
|
||||
|
||||
const baseRedirect: Redirect = {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useEffect, 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
domainId: string;
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const HandleForwardAuth = ({ domainId, applicationId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<string>("");
|
||||
|
||||
const { data: haveValidLicense } =
|
||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: status, isLoading: isLoadingStatus } =
|
||||
api.forwardAuth.status.useQuery({ domainId }, { enabled: isOpen });
|
||||
const { data: providers, isLoading: isLoadingProviders } =
|
||||
api.forwardAuth.listProviders.useQuery(undefined, { enabled: isOpen });
|
||||
|
||||
const { mutateAsync: enable, isPending: isEnabling } =
|
||||
api.forwardAuth.enable.useMutation();
|
||||
const { mutateAsync: disable, isPending: isDisabling } =
|
||||
api.forwardAuth.disable.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.providerId) {
|
||||
setSelectedProviderId(status.providerId);
|
||||
}
|
||||
}, [status?.providerId]);
|
||||
|
||||
if (!haveValidLicense) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isEnabled = !!status?.enabled;
|
||||
const hasProviders = (providers?.length ?? 0) > 0;
|
||||
|
||||
const refresh = async () => {
|
||||
await utils.forwardAuth.status.invalidate({ domainId });
|
||||
await utils.domain.byApplicationId.invalidate({ applicationId });
|
||||
await utils.application.readTraefikConfig.invalidate({ applicationId });
|
||||
};
|
||||
|
||||
const handleEnable = async () => {
|
||||
if (!selectedProviderId) {
|
||||
toast.error("Select an SSO provider first");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await enable({ domainId, providerId: selectedProviderId });
|
||||
await refresh();
|
||||
toast.success("SSO authentication enabled for this domain");
|
||||
setIsOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error enabling SSO",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
try {
|
||||
await disable({ domainId });
|
||||
await refresh();
|
||||
toast.success("SSO authentication disabled for this domain");
|
||||
setIsOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error disabling SSO",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{!isLoadingProviders && !hasProviders && (
|
||||
<AlertBlock type="warning">
|
||||
No SSO providers configured. Add an OIDC provider in your
|
||||
organization SSO settings first.
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
<AlertBlock type="info">
|
||||
Requires the authentication domain + proxy to be configured in SSO
|
||||
settings, and this app's domain to share its base domain.
|
||||
</AlertBlock>
|
||||
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium">Identity provider</span>
|
||||
<Select
|
||||
value={selectedProviderId}
|
||||
onValueChange={setSelectedProviderId}
|
||||
disabled={isLoadingStatus || isLoadingProviders || !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>
|
||||
|
||||
{isEnabled && (
|
||||
<AlertBlock type="info">
|
||||
SSO is currently enabled for this domain.
|
||||
</AlertBlock>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row justify-end gap-2">
|
||||
{isEnabled && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isDisabling}
|
||||
onClick={handleDisable}
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isLoading={isEnabling}
|
||||
disabled={!hasProviders || !selectedProviderId}
|
||||
onClick={handleEnable}
|
||||
>
|
||||
{isEnabled ? "Update" : "Enable"}
|
||||
</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,465 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Copy,
|
||||
Dices,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
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: 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.
|
||||
</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"
|
||||
/>
|
||||
<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,17 @@
|
||||
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,
|
||||
"organizationId" text NOT NULL,
|
||||
"serverId" text,
|
||||
"createdAt" text NOT NULL,
|
||||
CONSTRAINT "forward_auth_settings_organizationId_serverId_unique" UNIQUE("organizationId","serverId")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "domain" ADD COLUMN "forwardAuthProviderId" text;--> statement-breakpoint
|
||||
ALTER TABLE "forward_auth_settings" ADD CONSTRAINT "forward_auth_settings_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade 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;--> statement-breakpoint
|
||||
ALTER TABLE "domain" ADD CONSTRAINT "domain_forwardAuthProviderId_sso_provider_provider_id_fk" FOREIGN KEY ("forwardAuthProviderId") REFERENCES "public"."sso_provider"("provider_id") ON DELETE set null ON UPDATE no action;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "forward_auth_settings" ADD COLUMN "providerId" text;--> 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;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "forward_auth_settings" DROP CONSTRAINT "forward_auth_settings_organizationId_serverId_unique";--> statement-breakpoint
|
||||
ALTER TABLE "forward_auth_settings" DROP CONSTRAINT "forward_auth_settings_organizationId_organization_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "forward_auth_settings" DROP COLUMN "organizationId";--> statement-breakpoint
|
||||
ALTER TABLE "forward_auth_settings" ADD CONSTRAINT "forward_auth_settings_serverId_unique" UNIQUE("serverId");
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1191,6 +1191,27 @@
|
||||
"when": 1780127552074,
|
||||
"tag": "0169_parched_johnny_storm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 170,
|
||||
"version": "7",
|
||||
"when": 1780264266301,
|
||||
"tag": "0170_nostalgic_joshua_kane",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 171,
|
||||
"version": "7",
|
||||
"when": 1780352857424,
|
||||
"tag": "0171_normal_sleepwalker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 172,
|
||||
"version": "7",
|
||||
"when": 1780353694755,
|
||||
"tag": "0172_nice_impossible_man",
|
||||
"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">
|
||||
|
||||
@@ -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,172 @@
|
||||
import {
|
||||
assertApplicationDomainAccess,
|
||||
deployForwardAuthOnServer,
|
||||
disableForwardAuthOnDomain,
|
||||
enableForwardAuthOnDomain,
|
||||
findServerById,
|
||||
forwardAuthCallbackUrl,
|
||||
getDomainSsoStatus,
|
||||
getForwardAuthServerStatus,
|
||||
getForwardAuthSettings,
|
||||
listSsoProvidersForOrg,
|
||||
removeForwardAuthProxy,
|
||||
removeForwardAuthSettings,
|
||||
setForwardAuthSettings,
|
||||
} from "@dokploy/server";
|
||||
import { apiSetForwardAuthSettings } from "@dokploy/server/db/schema";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, enterpriseProcedure } from "@/server/api/trpc";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
|
||||
export const forwardAuthRouter = createTRPCRouter({
|
||||
getAuthDomain: enterpriseProcedure
|
||||
.input(z.object({ serverId: z.string().nullable() }))
|
||||
.query(async ({ input }) => {
|
||||
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) await findServerById(input.serverId);
|
||||
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(z.object({ serverId: z.string().nullable() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.serverId) await findServerById(input.serverId);
|
||||
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,
|
||||
ctx.session.userId,
|
||||
),
|
||||
),
|
||||
|
||||
serverStatus: enterpriseProcedure.query(({ ctx }) =>
|
||||
getForwardAuthServerStatus(ctx.session.activeOrganizationId),
|
||||
),
|
||||
|
||||
deployOnServer: enterpriseProcedure
|
||||
.input(
|
||||
z.object({
|
||||
serverId: z.string().nullable(),
|
||||
providerId: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.serverId) await findServerById(input.serverId);
|
||||
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(z.object({ serverId: z.string().nullable() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.serverId) await findServerById(input.serverId);
|
||||
const result = await removeForwardAuthProxy(input.serverId);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "server",
|
||||
resourceId: input.serverId ?? "local",
|
||||
resourceName: "forward-auth",
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
status: enterpriseProcedure
|
||||
.input(z.object({ domainId: z.string().min(1) }))
|
||||
.query(({ ctx, input }) => getDomainSsoStatus(ctx, input.domainId)),
|
||||
|
||||
enable: enterpriseProcedure
|
||||
.input(
|
||||
z.object({
|
||||
domainId: z.string().min(1),
|
||||
providerId: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const domain = await assertApplicationDomainAccess(
|
||||
ctx,
|
||||
input.domainId,
|
||||
"create",
|
||||
);
|
||||
const result = await enableForwardAuthOnDomain({
|
||||
domainId: input.domainId,
|
||||
providerId: input.providerId,
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
});
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "domain",
|
||||
resourceId: domain.domainId,
|
||||
resourceName: domain.host,
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
disable: enterpriseProcedure
|
||||
.input(z.object({ domainId: z.string().min(1) }))
|
||||
.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;
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
# Design: SSO Forward Auth for Deployed Applications (Enterprise)
|
||||
|
||||
**Status:** Approved for implementation (v1) — branch `feat/forward-auth-sso`
|
||||
**Author:** Engineering
|
||||
**Date:** 2026-05
|
||||
**Audience:** Internal + enterprise customer requesting the feature
|
||||
|
||||
## Decisions locked for v1
|
||||
|
||||
- **Auth gate:** Option A — integrate `oauth2-proxy` (do not build our own auth server).
|
||||
- **OIDC source:** reuse the existing `sso_provider` table (read-only from this feature).
|
||||
- **Auth domain per server, modeled as a `domains` row:** because each server is an isolated swarm
|
||||
with its own proxy (§6.1), each server has its **own** auth domain (e.g. `auth-prod.acme.com`)
|
||||
hosting that server's single oauth2 callback, registered **once** in the IdP per server. The auth
|
||||
domain is a row in the existing `domains` table with `domainType: "forwardAuth"` and a `serverId`
|
||||
(null = local host) — so it **inherits certificates, TLS and the domain pipeline** like any app
|
||||
domain, instead of a separate settings table. There is no `forward_auth_settings` table.
|
||||
- **`domains.forwardAuthProviderId`** (FK → `sso_provider.providerId`, `ON DELETE set null`) marks an
|
||||
**app** domain as protected by a provider; deleting the provider auto-unprotects the domain. This
|
||||
is distinct from the `forwardAuth` domain row, which is the gate itself.
|
||||
- **Why per-server (not one global auth domain):** a single `auth.acme.com` would resolve (DNS) to
|
||||
one server only, and the forwardAuth check runs over each server's *internal* network — a remote
|
||||
server can't reach another server's proxy without exposing it publicly. Per-server keeps every
|
||||
server autonomous (local forwardAuth, no cross-server traffic). The cost is one IdP callback per
|
||||
server, which is acceptable.
|
||||
- **Shared base domain assumption:** the auth domain and the protected apps on a server share a
|
||||
base domain, so the session cookie (scoped to `baseDomain`) works across that server's apps. Apps
|
||||
outside that base are out of scope for v1.
|
||||
- **Client secret at rest:** **deferred** — the `clientSecret` stays unencrypted in `oidcConfig`
|
||||
for v1 (same as today). Tracked as security debt in §10.
|
||||
- **oauth2-proxy quirks handled:** `--insecure-oidc-allow-unverified-email` (many IdPs send
|
||||
`email_verified=false` → otherwise a 500), and `whitelist-domains = baseDomain` (oauth2-proxy
|
||||
has no universal wildcard; the base domain covers every app under it).
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem statement
|
||||
|
||||
An enterprise customer wants to place an **SSO authentication gate in front of each deployed
|
||||
application** (the apps/compose services that Dokploy publishes through Traefik), so that an
|
||||
unauthenticated visitor must log in against the company's IdP (OIDC) before reaching the app.
|
||||
This should be an **enterprise-only** feature, and ideally should reuse the OIDC information we
|
||||
already store.
|
||||
|
||||
In short: *"Can we sit an SSO layer between Traefik and each application, reusing the OIDC
|
||||
tables?"*
|
||||
|
||||
**Answer: yes, it's feasible.** Traefik supports this natively via the `forwardAuth`
|
||||
middleware. The hard part is not Traefik — it's the **auth proxy service** that performs the
|
||||
OIDC flow. This doc compares the two viable ways to build that service, and confirms what we
|
||||
can and cannot reuse from the existing SSO tables.
|
||||
|
||||
---
|
||||
|
||||
## 2. Critical clarification: what our OIDC data actually is
|
||||
|
||||
The existing `sso_provider` table
|
||||
([`packages/server/src/db/schema/sso.ts:7`](../../packages/server/src/db/schema/sso.ts#L7))
|
||||
is owned by the **better-auth SSO plugin**. It exists so that **users can log into the Dokploy
|
||||
dashboard** against an external IdP (Dokploy acts as an OIDC/SAML *client*).
|
||||
|
||||
It stores, as JSON text columns:
|
||||
|
||||
- `oidcConfig` — `clientId`, `clientSecret`, `authorizationEndpoint`, `tokenEndpoint`,
|
||||
`userInfoEndpoint`, `jwksEndpoint`, `discoveryEndpoint`, `scopes`, `pkce`, and a `mapping`
|
||||
for user fields.
|
||||
- `samlConfig` — full SAML SP/IdP metadata.
|
||||
- `issuer`, `providerId` (unique), `domain`, `organizationId`, `userId`.
|
||||
|
||||
> ⚠️ Important security note: the `clientSecret` lives **inside the `oidcConfig` text column as
|
||||
> plain JSON and is not encrypted at rest** in the schema. Reusing this data for a second
|
||||
> purpose (see §4) means that secret gets read and re-injected into another service's config.
|
||||
> That widens its blast radius and must be called out to the customer. If we reuse it we should
|
||||
> seriously consider encrypting it at rest as part of this work.
|
||||
|
||||
**Key point for the customer:** this data describes Dokploy-as-an-OIDC-client. To protect their
|
||||
*applications*, we need a separate component (an auth proxy) that runs the OIDC
|
||||
**authorization-code flow on behalf of the protected app** — handle login redirect, callback,
|
||||
token validation, session cookie, and logout. better-auth's SSO plugin does **not** do this for
|
||||
third-party apps behind Traefik; it only logs users into Dokploy itself.
|
||||
|
||||
So "reuse the OIDC tables" is possible at the level of **credentials/endpoints** (clientId,
|
||||
secret, issuer, scopes), but the *runtime behavior* (the actual SSO gate) is net-new regardless
|
||||
of approach.
|
||||
|
||||
---
|
||||
|
||||
## 3. How the Traefik side works (the easy half)
|
||||
|
||||
Traefik's [`forwardAuth`](https://doc.traefik.io/traefik/middlewares/http/forwardauth/)
|
||||
middleware delegates the auth decision to an external HTTP service. For every request Traefik
|
||||
calls `address`; a `2xx` lets the request through (optionally copying `authResponseHeaders`
|
||||
back to the app), anything else (typically a `302` to the IdP) is returned to the browser.
|
||||
|
||||
Dokploy already has everything needed to wire this up:
|
||||
|
||||
| Capability | Where it lives today | Reuse |
|
||||
| --- | --- | --- |
|
||||
| `ForwardAuthMiddleware` Traefik type | [`utils/traefik/file-types.ts:659`](../../packages/server/src/utils/traefik/file-types.ts#L659) (`address`, `tls`, `trustForwardHeader`, `authResponseHeaders`, `authRequestHeaders`) | ✅ as-is |
|
||||
| Per-domain middleware chain | `domains.middlewares: text[]` column ([`db/schema/domain.ts`](../../packages/server/src/db/schema/domain.ts)) — already exists and is applied | ✅ as-is |
|
||||
| Attaching middleware to a router | `createDomainLabels()` joins `domain.middlewares` into `traefik.http.routers.<name>.middlewares` ([`utils/docker/domain.ts:255`](../../packages/server/src/utils/docker/domain.ts#L255)) | ✅ as-is |
|
||||
| Writing dynamic middleware YAML | `createSecurityMiddleware()` / `writeMiddleware()` pattern, local + remote(SSH) ([`utils/traefik/security.ts`](../../packages/server/src/utils/traefik/security.ts), [`middleware.ts`](../../packages/server/src/utils/traefik/middleware.ts)) | ✅ as pattern |
|
||||
| Deploying a helper container/service on the swarm | `dokploy-redis` / `dokploy-monitoring` / `dokploy-traefik` setup ([`setup/redis-setup.ts`](../../packages/server/src/setup/redis-setup.ts), [`monitoring-setup.ts`](../../packages/server/src/setup/monitoring-setup.ts), [`traefik-setup.ts`](../../packages/server/src/setup/traefik-setup.ts)) on `dokploy-network` | ✅ as pattern |
|
||||
| Enterprise gating | `enterpriseProcedure` + `hasValidLicense()` ([`server/api/trpc.ts:216`](../../apps/dokploy/server/api/trpc.ts#L216), [`services/proprietary/license-key.ts`](../../packages/server/src/services/proprietary/license-key.ts)) | ✅ as-is |
|
||||
|
||||
So the Dokploy-side glue (UI toggle on a domain → write a `forwardAuth` middleware → append its
|
||||
name to `domains.middlewares` → reload Traefik) is **small and low-risk**. The variable is the
|
||||
auth service that `address` points to.
|
||||
|
||||
---
|
||||
|
||||
## 4. The decision: where does the auth flow run?
|
||||
|
||||
This is the real fork. Both options use the *same* Traefik `forwardAuth` wiring from §3; they
|
||||
differ in what sits behind `address`.
|
||||
|
||||
### Option A — Integrate an existing forward-auth proxy (oauth2-proxy)
|
||||
|
||||
Deploy a battle-tested proxy (e.g. [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy)
|
||||
or `traefik-forward-auth`) as a Dokploy-managed Docker service, configured from the OIDC
|
||||
credentials. Dokploy generates the proxy config + the Traefik middleware; the proxy owns the
|
||||
OIDC flow, sessions, and cookies.
|
||||
|
||||
**Pros**
|
||||
- The security-critical part (OIDC flow, session/cookie handling, token refresh, logout, CSRF/
|
||||
state) is mature, audited, and maintained externally.
|
||||
- We write **config + deployment glue**, not an auth server. Far less code.
|
||||
- oauth2-proxy supports OIDC discovery, header injection, allowed-domains/groups, and Traefik
|
||||
forwardAuth mode out of the box.
|
||||
- Deployment follows an existing pattern (`dokploy-monitoring`/`dokploy-redis` style services
|
||||
on `dokploy-network`, local + remote via `serverId`).
|
||||
|
||||
**Cons**
|
||||
- A new bundled image to ship, version, and update across self-hosted + remote servers.
|
||||
- Per-org (or per-app) proxy instance + a session store (cookie-based or Redis) to manage.
|
||||
- Less branding control (login is the IdP's; the proxy is mostly invisible, which is usually
|
||||
fine).
|
||||
- Mapping our `oidcConfig` JSON → oauth2-proxy env/flags is a translation layer we must own and
|
||||
keep correct as configs vary (PKCE, custom endpoints, skipDiscovery, etc.).
|
||||
|
||||
### Option B — Build our own forward-auth service
|
||||
|
||||
Write a small Dokploy auth service that implements the OIDC authorization-code flow itself and
|
||||
answers Traefik's forwardAuth calls.
|
||||
|
||||
**Pros**
|
||||
- Full control over UX/branding, session model, and how it integrates with Dokploy
|
||||
orgs/permissions.
|
||||
- One codebase we fully understand; no third-party image to track.
|
||||
- Could share types/utilities with the rest of `packages/server`.
|
||||
|
||||
**Cons**
|
||||
- We are now building and **owning an authentication service** — sessions, signed/encrypted
|
||||
cookies, CSRF/state/nonce, token validation against JWKS, refresh, logout, clock-skew, replay
|
||||
protection. This is a large, security-sensitive surface that is easy to get subtly wrong.
|
||||
- The earlier "~200 LOC service" estimate is unrealistic; a correct implementation is
|
||||
substantially more, plus ongoing security maintenance.
|
||||
- We carry the liability for any auth bug in front of customer apps.
|
||||
|
||||
### Recommendation
|
||||
|
||||
**Option A (integrate oauth2-proxy).** The Traefik wiring is identical either way, so the only
|
||||
thing we're really choosing is whether to *own an auth server*. For a feature that gates access
|
||||
to customer production apps, delegating the auth flow to a mature project is the lower-risk,
|
||||
lower-cost, faster path. Build our own only if a hard requirement (deep branding, an unusual
|
||||
session model, air-gapped constraints) makes oauth2-proxy unworkable — none is evident yet.
|
||||
|
||||
---
|
||||
|
||||
## 5. Reusing `sso_provider` (per your decision)
|
||||
|
||||
You chose to **reuse the existing `sso_provider` OIDC config** rather than add an independent
|
||||
table. That's workable and minimizes setup for the customer, with these caveats to design
|
||||
around:
|
||||
|
||||
1. **Semantic coupling.** `sso_provider` currently means "how Dokploy users log into the
|
||||
dashboard." Reusing it for "how app visitors authenticate" overloads it. The IdP/client may
|
||||
legitimately need to differ (different OIDC client, different allowed audience, different
|
||||
redirect URIs — the app's callback, not Dokploy's). Mitigation: treat `sso_provider` as the
|
||||
*source of issuer + base credentials*, and add a thin per-domain config (which provider,
|
||||
plus app-specific redirect/allowed-groups) rather than assuming a 1:1 reuse.
|
||||
2. **Redirect URIs.** Each protected app needs its callback registered at the IdP
|
||||
(e.g. `https://app.customer.com/oauth2/callback`). The dashboard login uses Dokploy's own
|
||||
callback. The customer must add the app callbacks to the same OIDC client, or use a
|
||||
dedicated client. Document this clearly.
|
||||
3. **Secret handling.** As noted in §2, reading `clientSecret` out of `oidcConfig` and injecting
|
||||
it into oauth2-proxy means that secret now lives in a second place (proxy config/env on the
|
||||
target server). Recommend encrypting `oidcConfig` at rest and passing the secret to the proxy
|
||||
via a Docker secret / file mount rather than a plain env var.
|
||||
4. **better-auth ownership.** `register` currently round-trips through `auth.registerSSOProvider()`
|
||||
([`sso.ts:251`](../../apps/dokploy/server/api/routers/proprietary/sso.ts#L251)); rows may be
|
||||
written by an external auth service. We should **read** from `sso_provider` for forward-auth,
|
||||
but avoid mutating it through the forward-auth feature to prevent fighting better-auth over
|
||||
the same rows.
|
||||
|
||||
---
|
||||
|
||||
## 6. Proposed architecture (Option A)
|
||||
|
||||
```
|
||||
┌───────────────────────────┐
|
||||
Browser ──HTTPS──▶ Traefik ──forwardAuth──▶ oauth2-proxy (dokploy-managed)
|
||||
│ router for app.customer.com │
|
||||
│ middlewares=[sso-<provider>] │ OIDC auth-code flow
|
||||
│ ▼
|
||||
│ Customer IdP (OIDC)
|
||||
│ │
|
||||
◀───── 2xx + X-Auth-* headers ─────────┘
|
||||
│
|
||||
▼
|
||||
Deployed application
|
||||
```
|
||||
|
||||
**New/changed pieces (all enterprise-gated):**
|
||||
|
||||
1. **Helper service deployment** — a `dokploy-forward-auth` (oauth2-proxy) Docker service per
|
||||
org (or per server), modeled on `monitoring-setup.ts` / `redis-setup.ts`, attached to
|
||||
`dokploy-network`, supporting local + remote (`serverId`). Config derived from the chosen
|
||||
`sso_provider.oidcConfig`.
|
||||
2. **Traefik middleware generation** — a `createForwardAuthMiddleware()` following the
|
||||
`security.ts` pattern: write a `forwardAuth` entry (using `ForwardAuthMiddleware` from
|
||||
`file-types.ts`) to the dynamic middlewares file, `address` pointing at the helper service,
|
||||
with `authResponseHeaders` for the user identity headers.
|
||||
3. **Domain wiring** — UI toggle "Protect with SSO" on a domain + a field to pick the provider;
|
||||
appends the middleware name to the existing `domains.middlewares[]` and reloads Traefik. No
|
||||
schema change strictly required for the chain itself; a small column or join is needed to
|
||||
record *which* provider protects a domain.
|
||||
4. **tRPC router** — `forward-auth` router under `routers/proprietary/`, all `enterpriseProcedure`,
|
||||
with enable/disable-on-domain mutations.
|
||||
|
||||
---
|
||||
|
||||
### 6.1. Remote servers: one proxy per server
|
||||
|
||||
This is forced by Dokploy's networking model, not a design preference:
|
||||
|
||||
- **Each remote server is its own isolated Docker Swarm** (`docker swarm init` per server,
|
||||
[`server-setup.ts:381`](../../packages/server/src/setup/server-setup.ts#L381)).
|
||||
- **`dokploy-network` is an overlay local to each server's swarm**
|
||||
([`server-setup.ts:438`](../../packages/server/src/setup/server-setup.ts#L438)) — it does
|
||||
**not** span servers. A container on the Dokploy host cannot reach a container on a remote
|
||||
server over `dokploy-network`.
|
||||
- **Each server runs its own Traefik** ([`traefik-setup.ts:120`](../../packages/server/src/setup/traefik-setup.ts#L120));
|
||||
it only routes to services on that same server.
|
||||
|
||||
Therefore Traefik on server A can only `forwardAuth` to a proxy that lives **on server A**. The
|
||||
deployment model is **one `dokploy-forward-auth` instance per server** (host + each remote),
|
||||
exactly mirroring how `dokploy-monitoring` is already deployed per server via
|
||||
`getRemoteDocker(serverId)` ([`monitoring-setup.ts:10`](../../packages/server/src/setup/monitoring-setup.ts#L10)).
|
||||
One instance per server still protects *all* apps on that server (multi-upstream), so it is not
|
||||
one-per-app.
|
||||
|
||||
```
|
||||
Dokploy host: dokploy-forward-auth → protects local apps
|
||||
Remote server A: dokploy-forward-auth → protects A's apps
|
||||
Remote server B: dokploy-forward-auth → protects B's apps
|
||||
```
|
||||
|
||||
**Session scope (v1 = isolated per server):** because oauth2-proxy sessions are cookie-based per
|
||||
instance, a user moving between an app on server A and an app on server B may re-authenticate.
|
||||
v1 accepts this. To enable shared SSO later, point all instances at a common cookie domain and
|
||||
the same `cookie-secret`; v1 stores these in a structured config so flipping to shared mode is
|
||||
config-only, not a refactor.
|
||||
|
||||
**Lifecycle:** deploy/update the proxy per server during the `serverSetup` flow
|
||||
([`server-setup.ts:47`](../../packages/server/src/setup/server-setup.ts#L47)) and/or lazily the
|
||||
first time a domain on that server is protected.
|
||||
|
||||
### 6.2. Auth domain per server (the low-friction model)
|
||||
|
||||
The first iteration used a per-app callback (`https://app/oauth2/callback`), which meant: register
|
||||
a callback in the IdP **per app**, and update the proxy whitelist (a `service.update`) on every
|
||||
new protected domain. Too manual.
|
||||
|
||||
v1 uses **one auth domain per server** (each server is autonomous — §6.1):
|
||||
|
||||
```
|
||||
Per server (e.g. "Production"):
|
||||
1. Admin sets "auth-prod.acme.com" for that server in SSO settings (once).
|
||||
→ a Traefik router auth-prod.acme.com/oauth2/* → that server's oauth2-proxy
|
||||
→ ONE callback to register in the IdP: https://auth-prod.acme.com/oauth2/callback
|
||||
|
||||
2. app1.acme.com on Production (SSO enabled):
|
||||
- no session → forwardAuth 401 → errors middleware 302s the browser to
|
||||
https://auth-prod.acme.com/oauth2/sign_in?rd=<app1 url>
|
||||
- login at IdP → returns to auth-prod.acme.com/oauth2/callback (the one registered)
|
||||
- cookie scoped to .acme.com → redirect back to app1.acme.com ✅
|
||||
|
||||
3. app2.acme.com, app3.acme.com on the same server:
|
||||
- same flow, same callback, same cookie. ZERO new IdP config, ZERO proxy redeploy. ✅
|
||||
```
|
||||
|
||||
Why it removes both pain points (within a server):
|
||||
- **One IdP callback per server:** the redirect_uri is always that server's
|
||||
`auth-<server>.acme.com/oauth2/callback`, configured once per server.
|
||||
- **No per-app redeploy:** cookie + whitelist are scoped to `baseDomain`, which already covers any
|
||||
new subdomain on that server.
|
||||
|
||||
Wiring summary:
|
||||
- `forward_auth_settings`, unique per `(organizationId, serverId)`: `authDomain`, `baseDomain`
|
||||
(derived, e.g. `.acme.com`), `https`. `serverId = null` = local host.
|
||||
- Proxy env (per server): `redirect-url = <scheme>://authDomain/oauth2/callback`,
|
||||
`cookie-domains = baseDomain`, `whitelist-domains = baseDomain`, per-server `cookie-secret`.
|
||||
- Traefik: a dedicated `forward-auth-domain.yml` router for `authDomain/oauth2/*` → proxy on that
|
||||
server; each protected app gets a `forwardAuth` + an `errors` middleware that 302s to its
|
||||
server's auth domain login. The middleware resolves which auth domain to use from the app's
|
||||
`serverId`.
|
||||
|
||||
Limitations (out of scope for v1):
|
||||
- Apps **not** under their server's `baseDomain` won't get shared SSO (cross-domain cookies).
|
||||
- SSO is **not** shared across servers (a user moving between apps on different servers logs in
|
||||
again). True cross-server SSO would require exposing one proxy publicly for cross-server
|
||||
forwardAuth — deliberately avoided for autonomy/latency.
|
||||
|
||||
## 7. Open questions for the customer / product
|
||||
|
||||
- **Granularity:** protect per *domain*, per *application*, or per *project/environment*?
|
||||
- **Session scope:** single sign-on shared across all protected apps on a base domain, or
|
||||
isolated per app? (Affects cookie domain + whether one proxy instance is shared.)
|
||||
- **Authorization, not just authentication:** do they need group/role-based allow rules
|
||||
(e.g. only `group=engineering`), or is "any authenticated user from the IdP" enough?
|
||||
- **Remote servers:** must this work on remote (SSH-managed) servers from day one, or
|
||||
local/Dokploy-host only for v1?
|
||||
- **Logout / session lifetime** expectations.
|
||||
- **Dedicated OIDC client** for app protection vs reusing the dashboard-login client.
|
||||
|
||||
---
|
||||
|
||||
## 8. Effort estimate (Option A, design-validated)
|
||||
|
||||
Assumes oauth2-proxy, reuse of `sso_provider`, local + remote support, one provider per domain.
|
||||
|
||||
| Workstream | Rough effort |
|
||||
| --- | --- |
|
||||
| Helper service deploy (image choice, setup module, local+remote, lifecycle) | 3–5 d |
|
||||
| OIDC config → proxy config translation layer (incl. secret handling) | 2–3 d |
|
||||
| `createForwardAuthMiddleware()` + dynamic file write/reload (local+remote) | 2–3 d |
|
||||
| Domain wiring + provider linkage (schema touch, labels, enable/disable) | 2–3 d |
|
||||
| tRPC router + UI (toggle, provider select, status) | 2–3 d |
|
||||
| Security review, encryption-at-rest for secret, testing | 3–4 d |
|
||||
| **Total** | **~14–21 d** |
|
||||
|
||||
Option B (own auth service) is **meaningfully larger** — add the full auth-server build plus
|
||||
ongoing security ownership; do not estimate it as a small delta over A.
|
||||
|
||||
---
|
||||
|
||||
## 9. Recommendation summary
|
||||
|
||||
- **Feasible: yes.** Traefik `forwardAuth` + Dokploy's existing middleware/deploy patterns make
|
||||
the integration straightforward.
|
||||
- **Build the gate with oauth2-proxy (Option A)**, not a hand-rolled auth server.
|
||||
- **Reuse `sso_provider` for credentials/endpoints**, but add a thin per-domain link and treat
|
||||
app callbacks/redirects as distinct from dashboard login. Client-secret encryption at rest is
|
||||
**deferred** (see §10).
|
||||
- Gate everything behind `enterpriseProcedure` + valid license, consistent with existing SSO.
|
||||
- Resolve the §7 product questions (granularity, authorization rules, remote-server scope)
|
||||
before committing to the estimate.
|
||||
|
||||
---
|
||||
|
||||
## 10. Security debt (deferred to a follow-up)
|
||||
|
||||
These are knowingly accepted for v1 and must be tracked, not forgotten:
|
||||
|
||||
1. **`clientSecret` unencrypted at rest.** `oidcConfig` (incl. `clientSecret`) remains plain
|
||||
JSON in the DB, as it is today. Reusing it for forward-auth propagates the secret to each
|
||||
server's proxy config. **Follow-up:** add encrypt/decrypt for `oidcConfig` and rotate.
|
||||
2. **Secret transport to proxy.** Even in v1, pass `clientSecret` to oauth2-proxy via a Docker
|
||||
secret / mounted file, **not** a plain env var, to keep it out of `docker inspect` output.
|
||||
3. **Trusted proxy.** Configure oauth2-proxy `--reverse-proxy=true` and restrict
|
||||
`--trusted-proxy-ip` to the Traefik instance so forwarded identity headers can't be spoofed
|
||||
by the upstream app or other containers.
|
||||
4. **Cross-server shared session (deferred).** v1 is isolated per server (§6.1); shared SSO is a
|
||||
config flip later, not built now.
|
||||
@@ -16,6 +16,7 @@ import { applications } from "./application";
|
||||
import { compose } from "./compose";
|
||||
import { previewDeployments } from "./preview-deployments";
|
||||
import { certificateType } from "./shared";
|
||||
import { ssoProvider } from "./sso";
|
||||
|
||||
export const domainType = pgEnum("domainType", [
|
||||
"compose",
|
||||
@@ -55,6 +56,10 @@ export const domains = pgTable("domain", {
|
||||
internalPath: text("internalPath").default("/"),
|
||||
stripPath: boolean("stripPath").notNull().default(false),
|
||||
middlewares: text("middlewares").array().default(sql`ARRAY[]::text[]`),
|
||||
forwardAuthProviderId: text("forwardAuthProviderId").references(
|
||||
() => ssoProvider.providerId,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
});
|
||||
|
||||
export const domainsRelations = relations(domains, ({ one }) => ({
|
||||
@@ -70,6 +75,10 @@ export const domainsRelations = relations(domains, ({ one }) => ({
|
||||
fields: [domains.previewDeploymentId],
|
||||
references: [previewDeployments.previewDeploymentId],
|
||||
}),
|
||||
forwardAuthProvider: one(ssoProvider, {
|
||||
fields: [domains.forwardAuthProviderId],
|
||||
references: [ssoProvider.providerId],
|
||||
}),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(domains, {
|
||||
@@ -94,6 +103,7 @@ export const apiCreateDomain = createSchema.pick({
|
||||
internalPath: true,
|
||||
stripPath: true,
|
||||
middlewares: true,
|
||||
forwardAuthProviderId: true,
|
||||
});
|
||||
|
||||
export const apiFindDomain = z.object({
|
||||
@@ -126,5 +136,6 @@ export const apiUpdateDomain = createSchema
|
||||
internalPath: true,
|
||||
stripPath: true,
|
||||
middlewares: true,
|
||||
forwardAuthProviderId: true,
|
||||
})
|
||||
.merge(createSchema.pick({ domainId: true }).required());
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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 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(),
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -173,31 +173,29 @@ export const apiModifyTraefikConfig = z.object({
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
export const apiReadTraefikConfig = z.object({
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(path) => {
|
||||
// Prevent directory traversal attacks
|
||||
if (path.includes("../") || path.includes("..\\")) {
|
||||
return false;
|
||||
}
|
||||
path: z.string().min(1),
|
||||
// .refine(
|
||||
// (path) => {
|
||||
// // Prevent directory traversal attacks
|
||||
// if (path.includes("../") || path.includes("..\\")) {
|
||||
// return false;
|
||||
// }
|
||||
|
||||
const { MAIN_TRAEFIK_PATH } = paths();
|
||||
if (path.startsWith("/") && !path.startsWith(MAIN_TRAEFIK_PATH)) {
|
||||
return false;
|
||||
}
|
||||
// Prevent null bytes and other dangerous characters
|
||||
if (path.includes("\0") || path.includes("\x00")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Invalid path: path traversal or unauthorized directory access detected",
|
||||
},
|
||||
),
|
||||
// const { MAIN_TRAEFIK_PATH } = paths();
|
||||
// if (path.startsWith("/") && !path.startsWith(MAIN_TRAEFIK_PATH)) {
|
||||
// return false;
|
||||
// }
|
||||
// // Prevent null bytes and other dangerous characters
|
||||
// if (path.includes("\0") || path.includes("\x00")) {
|
||||
// return false;
|
||||
// }
|
||||
// return true;
|
||||
// },
|
||||
// {
|
||||
// message:
|
||||
// "Invalid path: path traversal or unauthorized directory access detected",
|
||||
// },
|
||||
// ),
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -100,6 +102,7 @@ export * from "./utils/docker/types";
|
||||
export * from "./utils/docker/utils";
|
||||
export * from "./utils/filesystem/directory";
|
||||
export * from "./utils/filesystem/ssh";
|
||||
export * from "./utils/git-branch-validation";
|
||||
export * from "./utils/gpu-setup";
|
||||
export * from "./utils/notifications/build-error";
|
||||
export * from "./utils/notifications/build-success";
|
||||
@@ -108,7 +111,6 @@ export * from "./utils/notifications/docker-cleanup";
|
||||
export * from "./utils/notifications/dokploy-restart";
|
||||
export * from "./utils/notifications/server-threshold";
|
||||
export * from "./utils/notifications/utils";
|
||||
export * from "./utils/git-branch-validation";
|
||||
export * from "./utils/process/execAsync";
|
||||
export * from "./utils/process/spawnAsync";
|
||||
export * from "./utils/providers/bitbucket";
|
||||
@@ -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,377 @@
|
||||
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,
|
||||
userId: string,
|
||||
) => {
|
||||
return db.query.ssoProvider.findMany({
|
||||
where: and(
|
||||
eq(ssoProvider.organizationId, organizationId),
|
||||
eq(ssoProvider.userId, userId),
|
||||
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.forwardAuthProviderId,
|
||||
providerId: domain.forwardAuthProviderId ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
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 },
|
||||
orderBy: [desc(server.createdAt)],
|
||||
});
|
||||
|
||||
const targets: { serverId: string | null; name: string }[] = [
|
||||
{ serverId: null, name: "Dokploy Server (local)" },
|
||||
...servers.map((s) => ({ serverId: s.serverId, name: s.name })),
|
||||
];
|
||||
|
||||
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;
|
||||
providerId: string;
|
||||
organizationId: string;
|
||||
}) => {
|
||||
const { application } = await resolveApplicationDomain(input.domainId);
|
||||
await findProviderForOrg(input.providerId, input.organizationId);
|
||||
const serverId = application.serverId ?? undefined;
|
||||
|
||||
const settings = await getForwardAuthSettings(serverId ?? null);
|
||||
if (!settings) {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message:
|
||||
"Set the authentication domain and deploy the proxy for this server 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, {
|
||||
forwardAuthProviderId: input.providerId,
|
||||
});
|
||||
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, { forwardAuthProviderId: null });
|
||||
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.forwardAuthProviderId) {
|
||||
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.forwardAuthProviderId) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user