mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02: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 () => {
|
||||
|
||||
233
apps/dokploy/__test__/traefik/forward-auth.test.ts
Normal file
233
apps/dokploy/__test__/traefik/forward-auth.test.ts
Normal file
@@ -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"
|
||||
|
||||
465
apps/dokploy/components/proprietary/sso/forward-auth-servers.tsx
Normal file
465
apps/dokploy/components/proprietary/sso/forward-auth-servers.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
17
apps/dokploy/drizzle/0170_nostalgic_joshua_kane.sql
Normal file
17
apps/dokploy/drizzle/0170_nostalgic_joshua_kane.sql
Normal file
@@ -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;
|
||||
2
apps/dokploy/drizzle/0171_normal_sleepwalker.sql
Normal file
2
apps/dokploy/drizzle/0171_normal_sleepwalker.sql
Normal file
@@ -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;
|
||||
5
apps/dokploy/drizzle/0172_nice_impossible_man.sql
Normal file
5
apps/dokploy/drizzle/0172_nice_impossible_man.sql
Normal file
@@ -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");
|
||||
8457
apps/dokploy/drizzle/meta/0170_snapshot.json
Normal file
8457
apps/dokploy/drizzle/meta/0170_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8476
apps/dokploy/drizzle/meta/0171_snapshot.json
Normal file
8476
apps/dokploy/drizzle/meta/0171_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8456
apps/dokploy/drizzle/meta/0172_snapshot.json
Normal file
8456
apps/dokploy/drizzle/meta/0172_snapshot.json
Normal file
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,
|
||||
|
||||
172
apps/dokploy/server/api/routers/proprietary/forward-auth.ts
Normal file
172
apps/dokploy/server/api/routers/proprietary/forward-auth.ts
Normal file
@@ -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;
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user