mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-09 16:05:23 +02:00
Merge pull request #4771 from Dokploy/feat/scim
feat(scim): SCIM 2.0 user provisioning (enterprise)
This commit is contained in:
@@ -97,6 +97,7 @@ export const ShowUsers = () => {
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Email</TableHead>
|
||||
<TableHead className="text-center">Role</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
<TableHead className="text-center">2FA</TableHead>
|
||||
|
||||
<TableHead className="text-center">
|
||||
@@ -173,6 +174,19 @@ export const ShowUsers = () => {
|
||||
{member.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant={
|
||||
member.user.banned
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{member.user.banned
|
||||
? "Deactivated"
|
||||
: "Active"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{member.user.twoFactorEnabled
|
||||
? "Enabled"
|
||||
|
||||
235
apps/dokploy/components/proprietary/sso/scim-dialog.tsx
Normal file
235
apps/dokploy/components/proprietary/sso/scim-dialog.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import copy from "copy-to-clipboard";
|
||||
import { Copy, KeyRound, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { useUrl } from "@/utils/hooks/use-url";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ScimDialog = ({ children }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const baseURL = useUrl();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newProviderId, setNewProviderId] = useState("");
|
||||
const [justCreatedToken, setJustCreatedToken] = useState<{
|
||||
providerId: string;
|
||||
token: string;
|
||||
} | null>(null);
|
||||
|
||||
const { data: providers = [], isPending } = api.scim.listProviders.useQuery(
|
||||
undefined,
|
||||
{ enabled: open },
|
||||
);
|
||||
const { mutateAsync: generateToken, isPending: isGenerating } =
|
||||
api.scim.generateToken.useMutation();
|
||||
const { mutateAsync: deleteProvider, isPending: isDeleting } =
|
||||
api.scim.deleteProvider.useMutation();
|
||||
|
||||
const scimUrl = `${baseURL || "{baseURL}"}/api/auth/scim/v2`;
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const providerId = newProviderId.trim().toLowerCase();
|
||||
if (!providerId) return;
|
||||
try {
|
||||
const result = await generateToken({ providerId });
|
||||
setJustCreatedToken({
|
||||
providerId: result.providerId,
|
||||
token: result.scimToken,
|
||||
});
|
||||
setNewProviderId("");
|
||||
await utils.scim.listProviders.invalidate();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to generate SCIM token",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (providerId: string) => {
|
||||
try {
|
||||
await deleteProvider({ providerId });
|
||||
toast.success("SCIM provider removed");
|
||||
await utils.scim.listProviders.invalidate();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to delete SCIM provider",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (value: string, label: string) => {
|
||||
copy(value);
|
||||
toast.success(`${label} copied`);
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) setJustCreatedToken(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<KeyRound className="size-5" />
|
||||
SCIM provisioning
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Automatically provision, update, and deactivate users from your
|
||||
identity provider (Okta, Entra ID, etc.). Configure the SCIM
|
||||
endpoint below in your IdP.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
SCIM 2.0 endpoint URL
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="flex-1 break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
|
||||
{scimUrl}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
onClick={() => handleCopy(scimUrl, "Endpoint URL")}
|
||||
disabled={!baseURL}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{justCreatedToken && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3">
|
||||
<p className="text-sm font-medium">
|
||||
Bearer token for {justCreatedToken.providerId}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Copy this token now — it will not be shown again. Paste it into
|
||||
your IdP's SCIM configuration.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<p className="flex-1 break-all rounded-md bg-background px-2 py-1.5 font-mono text-xs">
|
||||
{justCreatedToken.token}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
onClick={() =>
|
||||
handleCopy(justCreatedToken.token, "Bearer token")
|
||||
}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
Generate token for a new provider
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newProviderId}
|
||||
onChange={(e) => setNewProviderId(e.target.value)}
|
||||
placeholder="okta, entra, jumpcloud..."
|
||||
className="font-mono text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleGenerate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
disabled={!newProviderId.trim() || isGenerating}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose a unique identifier for this IdP connection (lowercase,
|
||||
alphanumeric, dashes).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Existing providers</Label>
|
||||
{isPending ? (
|
||||
<div className="flex items-center gap-2 justify-center py-4">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</span>
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
No SCIM providers configured yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{providers.map((provider) => (
|
||||
<li
|
||||
key={provider.id}
|
||||
className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2"
|
||||
>
|
||||
<span className="flex-1 font-mono text-sm">
|
||||
{provider.providerId}
|
||||
</span>
|
||||
<DialogAction
|
||||
title="Remove SCIM provider"
|
||||
description={`Remove "${provider.providerId}"? Existing provisioned users will stay but the IdP will no longer be able to sync.`}
|
||||
type="destructive"
|
||||
onClick={() => handleDelete(provider.providerId)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 text-destructive hover:text-destructive"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
Eye,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
LogIn,
|
||||
Pencil,
|
||||
@@ -34,6 +35,7 @@ import { api } from "@/utils/api";
|
||||
import { useUrl } from "@/utils/hooks/use-url";
|
||||
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
||||
import { RegisterSamlDialog } from "./register-saml-dialog";
|
||||
import { ScimDialog } from "./scim-dialog";
|
||||
|
||||
type ProviderForDetails = {
|
||||
id: string | null;
|
||||
@@ -169,15 +171,22 @@ export const SSOSettings = () => {
|
||||
Users can sign in with their organization's IdP.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setManageOriginsOpen(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Shield className="mr-2 size-4" />
|
||||
Manage origins
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setManageOriginsOpen(true)}
|
||||
>
|
||||
<Shield className="mr-2 size-4" />
|
||||
Manage origins
|
||||
</Button>
|
||||
<ScimDialog>
|
||||
<Button variant="outline" size="sm">
|
||||
<KeyRound className="mr-2 size-4" />
|
||||
Manage SCIM
|
||||
</Button>
|
||||
</ScimDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
|
||||
13
apps/dokploy/drizzle/0173_aspiring_annihilus.sql
Normal file
13
apps/dokploy/drizzle/0173_aspiring_annihilus.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "scim_provider" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"scim_token" text NOT NULL,
|
||||
"organization_id" text,
|
||||
CONSTRAINT "scim_provider_provider_id_unique" UNIQUE("provider_id"),
|
||||
CONSTRAINT "scim_provider_scim_token_unique" UNIQUE("scim_token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "two_factor" ADD COLUMN "verified" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "two_factor" ADD COLUMN "failed_verification_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "two_factor" ADD COLUMN "locked_until" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "scim_provider" ADD CONSTRAINT "scim_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
|
||||
8544
apps/dokploy/drizzle/meta/0173_snapshot.json
Normal file
8544
apps/dokploy/drizzle/meta/0173_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1212,6 +1212,13 @@
|
||||
"when": 1781045439162,
|
||||
"tag": "0172_quick_the_professor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 173,
|
||||
"version": "7",
|
||||
"when": 1783494977500,
|
||||
"tag": "0173_aspiring_annihilus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -47,8 +47,8 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@better-auth/api-key": "1.5.4",
|
||||
"@better-auth/sso": "1.5.4",
|
||||
"@better-auth/api-key": "1.6.23",
|
||||
"@better-auth/sso": "1.6.23",
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
@@ -82,7 +82,7 @@
|
||||
"ai": "^6.0.86",
|
||||
"ai-sdk-ollama": "^3.7.0",
|
||||
"bcrypt": "5.1.1",
|
||||
"better-auth": "1.5.4",
|
||||
"better-auth": "1.6.23",
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"bullmq": "5.67.3",
|
||||
@@ -94,7 +94,7 @@
|
||||
"dockerode": "4.0.2",
|
||||
"dompurify": "^3.3.3",
|
||||
"dotenv": "16.4.5",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-zod": "0.8.3",
|
||||
"fancy-ansi": "^0.1.3",
|
||||
"input-otp": "^1.4.2",
|
||||
|
||||
@@ -32,6 +32,7 @@ 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 { scimRouter } from "./routers/proprietary/scim";
|
||||
import { ssoRouter } from "./routers/proprietary/sso";
|
||||
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||
import { redirectsRouter } from "./routers/redirects";
|
||||
@@ -94,6 +95,7 @@ export const appRouter = createTRPCRouter({
|
||||
organization: organizationRouter,
|
||||
licenseKey: licenseKeyRouter,
|
||||
sso: ssoRouter,
|
||||
scim: scimRouter,
|
||||
forwardAuth: forwardAuthRouter,
|
||||
whitelabeling: whitelabelingRouter,
|
||||
customRole: customRoleRouter,
|
||||
|
||||
75
apps/dokploy/server/api/routers/proprietary/scim.ts
Normal file
75
apps/dokploy/server/api/routers/proprietary/scim.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { scimProvider } from "@dokploy/server/db/schema";
|
||||
import { requestToHeaders } from "@dokploy/server/index";
|
||||
import { auth } from "@dokploy/server/lib/auth";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, enterpriseProcedure } from "@/server/api/trpc";
|
||||
|
||||
const providerIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(
|
||||
/^[a-z0-9][a-z0-9-]*$/,
|
||||
"Provider ID must be lowercase alphanumeric with optional dashes",
|
||||
);
|
||||
|
||||
export const scimRouter = createTRPCRouter({
|
||||
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
|
||||
const providers = await db.query.scimProvider.findMany({
|
||||
where: eq(scimProvider.organizationId, ctx.session.activeOrganizationId),
|
||||
columns: {
|
||||
id: true,
|
||||
providerId: true,
|
||||
organizationId: true,
|
||||
},
|
||||
orderBy: [asc(scimProvider.providerId)],
|
||||
});
|
||||
return providers;
|
||||
}),
|
||||
generateToken: enterpriseProcedure
|
||||
.input(z.object({ providerId: providerIdSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await db.query.scimProvider.findFirst({
|
||||
where: eq(scimProvider.providerId, input.providerId),
|
||||
columns: { id: true, organizationId: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "A SCIM provider with this ID already exists",
|
||||
});
|
||||
}
|
||||
const result = await auth.generateSCIMToken({
|
||||
body: {
|
||||
providerId: input.providerId,
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
},
|
||||
headers: requestToHeaders(ctx.req),
|
||||
});
|
||||
return { scimToken: result.scimToken, providerId: input.providerId };
|
||||
}),
|
||||
deleteProvider: enterpriseProcedure
|
||||
.input(z.object({ providerId: providerIdSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [deleted] = await db
|
||||
.delete(scimProvider)
|
||||
.where(
|
||||
and(
|
||||
eq(scimProvider.providerId, input.providerId),
|
||||
eq(scimProvider.organizationId, ctx.session.activeOrganizationId),
|
||||
),
|
||||
)
|
||||
.returning({ id: scimProvider.id });
|
||||
if (!deleted) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message:
|
||||
"SCIM provider not found or you do not have permission to delete it",
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -46,7 +46,9 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"esbuild": "0.20.2"
|
||||
"esbuild": "0.20.2",
|
||||
"better-call": "1.3.7",
|
||||
"@better-fetch/fetch": "1.3.1"
|
||||
},
|
||||
"peerDependencyRules": {
|
||||
"ignoreMissing": [
|
||||
|
||||
@@ -22,6 +22,9 @@ export const user = pgTable("user", {
|
||||
.notNull(),
|
||||
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||
role: text("role"),
|
||||
banned: boolean("banned").default(false),
|
||||
banReason: text("ban_reason"),
|
||||
banExpires: timestamp("ban_expires"),
|
||||
ownerId: text("owner_id"),
|
||||
allowImpersonation: boolean("allow_impersonation").default(false),
|
||||
lastName: text("last_name").default(""),
|
||||
@@ -45,6 +48,7 @@ export const session = pgTable(
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
impersonatedBy: text("impersonated_by"),
|
||||
},
|
||||
(table) => [index("session_userId_idx").on(table.userId)],
|
||||
);
|
||||
@@ -142,6 +146,9 @@ export const twoFactor = pgTable(
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
verified: boolean("verified").default(true),
|
||||
failedVerificationCount: integer("failed_verification_count").default(0),
|
||||
lockedUntil: timestamp("locked_until"),
|
||||
},
|
||||
(table) => [
|
||||
index("twoFactor_secret_idx").on(table.secret),
|
||||
@@ -223,6 +230,13 @@ export const invitation = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const scimProvider = pgTable("scim_provider", {
|
||||
id: text("id").primaryKey(),
|
||||
providerId: text("provider_id").notNull().unique(),
|
||||
scimToken: text("scim_token").notNull().unique(),
|
||||
organizationId: text("organization_id"),
|
||||
});
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
|
||||
@@ -37,9 +37,10 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@better-auth/api-key": "1.5.4",
|
||||
"@better-auth/sso": "1.5.4",
|
||||
"@better-auth/utils": "0.3.1",
|
||||
"@better-auth/api-key": "1.6.23",
|
||||
"@better-auth/scim": "1.6.23",
|
||||
"@better-auth/sso": "1.6.23",
|
||||
"@better-auth/utils": "0.4.2",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@octokit/rest": "^20.1.2",
|
||||
@@ -51,15 +52,14 @@
|
||||
"ai": "^6.0.86",
|
||||
"ai-sdk-ollama": "^3.7.0",
|
||||
"bcrypt": "5.1.1",
|
||||
"better-auth": "1.5.4",
|
||||
"better-call": "2.0.2",
|
||||
"better-auth": "1.6.23",
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"date-fns": "3.6.0",
|
||||
"dockerode": "4.0.2",
|
||||
"dotenv": "16.4.5",
|
||||
"drizzle-dbml-generator": "0.10.0",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"lodash": "4.17.21",
|
||||
"micromatch": "4.0.8",
|
||||
@@ -87,7 +87,6 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@better-auth/cli": "1.4.21",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/dockerode": "3.3.23",
|
||||
|
||||
@@ -214,6 +214,11 @@ export const twoFactor = pgTable("two_factor", {
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
verified: boolean("verified").notNull().default(true),
|
||||
failedVerificationCount: integer("failed_verification_count")
|
||||
.notNull()
|
||||
.default(0),
|
||||
lockedUntil: timestamp("locked_until"),
|
||||
});
|
||||
|
||||
export const apikey = pgTable("apikey", {
|
||||
|
||||
@@ -31,6 +31,7 @@ export * from "./redis";
|
||||
export * from "./registry";
|
||||
export * from "./rollbacks";
|
||||
export * from "./schedule";
|
||||
export * from "./scim";
|
||||
export * from "./security";
|
||||
export * from "./server";
|
||||
export * from "./session";
|
||||
|
||||
22
packages/server/src/db/schema/scim.ts
Normal file
22
packages/server/src/db/schema/scim.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { organization } from "./account";
|
||||
|
||||
export const scimProvider = pgTable("scim_provider", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
providerId: text("provider_id").notNull().unique(),
|
||||
scimToken: text("scim_token").notNull().unique(),
|
||||
organizationId: text("organization_id").references(() => organization.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
});
|
||||
|
||||
export const scimProviderRelations = relations(scimProvider, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [scimProvider.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
}));
|
||||
47
packages/server/src/lib/auth-cli.ts
Normal file
47
packages/server/src/lib/auth-cli.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { apiKey } from "@better-auth/api-key";
|
||||
import { scim } from "@better-auth/scim";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { admin, organization, twoFactor } from "better-auth/plugins";
|
||||
import { db } from "../db";
|
||||
import * as schema from "../db/schema";
|
||||
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
|
||||
|
||||
// CLI-only config for `@better-auth/cli generate` — must mirror the plugin set
|
||||
// in auth.ts. Never import this from runtime code.
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema,
|
||||
}),
|
||||
user: {
|
||||
modelName: "user",
|
||||
fields: {
|
||||
name: "firstName",
|
||||
},
|
||||
additionalFields: {
|
||||
role: { type: "string", input: false },
|
||||
ownerId: { type: "string", input: false },
|
||||
allowImpersonation: { type: "boolean", defaultValue: false },
|
||||
lastName: { type: "string", required: false, defaultValue: "" },
|
||||
enableEnterpriseFeatures: { type: "boolean", required: false },
|
||||
isValidEnterpriseLicense: { type: "boolean", required: false },
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
apiKey({ enableMetadata: true, references: "user" }),
|
||||
sso(),
|
||||
twoFactor(),
|
||||
organization({
|
||||
ac,
|
||||
roles: { owner: ownerRole, admin: adminRole, member: memberRole },
|
||||
dynamicAccessControl: {
|
||||
enabled: true,
|
||||
maximumRolesPerOrganization: 10,
|
||||
},
|
||||
}),
|
||||
scim(),
|
||||
admin(),
|
||||
],
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { apiKey } from "@better-auth/api-key";
|
||||
import { scim } from "@better-auth/scim";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import * as bcrypt from "bcrypt";
|
||||
import { betterAuth } from "better-auth";
|
||||
@@ -192,7 +193,8 @@ const { handler, api } = betterAuth({
|
||||
}
|
||||
} else {
|
||||
const isSSORequest = context?.path.includes("/sso");
|
||||
if (isSSORequest) {
|
||||
const isSCIMRequest = context?.path.includes("/scim");
|
||||
if (isSSORequest || isSCIMRequest) {
|
||||
return;
|
||||
}
|
||||
const isAdminPresent = await db.query.member.findFirst({
|
||||
@@ -208,6 +210,7 @@ const { handler, api } = betterAuth({
|
||||
},
|
||||
after: async (user, context) => {
|
||||
const isSSORequest = context?.path.includes("/sso");
|
||||
const isSCIMRequest = context?.path.includes("/scim");
|
||||
const isAdminPresent = await db.query.member.findFirst({
|
||||
where: eq(schema.member.role, "owner"),
|
||||
});
|
||||
@@ -243,6 +246,10 @@ const { handler, api } = betterAuth({
|
||||
}
|
||||
}
|
||||
|
||||
if (isSCIMRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (IS_CLOUD || !isAdminPresent) {
|
||||
await db.transaction(async (tx) => {
|
||||
const organization = await tx
|
||||
@@ -410,7 +417,25 @@ const { handler, api } = betterAuth({
|
||||
enableMetadata: true,
|
||||
references: "user",
|
||||
}),
|
||||
sso(),
|
||||
sso({
|
||||
saml: {
|
||||
enableInResponseToValidation: false,
|
||||
},
|
||||
}),
|
||||
scim({
|
||||
beforeSCIMTokenGenerated: async ({ user }) => {
|
||||
const dbUser = await db.query.user.findFirst({
|
||||
where: eq(schema.user.id, user.id),
|
||||
columns: { enableEnterpriseFeatures: true },
|
||||
});
|
||||
|
||||
if (!dbUser?.enableEnterpriseFeatures) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: "SCIM provisioning requires an enterprise license",
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
twoFactor(),
|
||||
organization({
|
||||
ac,
|
||||
@@ -424,13 +449,14 @@ const { handler, api } = betterAuth({
|
||||
maximumRolesPerOrganization: 10,
|
||||
},
|
||||
}),
|
||||
...(IS_CLOUD
|
||||
? [
|
||||
admin({
|
||||
adminUserIds: [process.env.USER_ADMIN_ID as string],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
// Self-hosted needs the admin plugin too: SCIM deactivation (active: false)
|
||||
// maps to the admin plugin's `banned` field and is rejected without it.
|
||||
// adminRoles: [] keeps every /admin/* endpoint locked on self-hosted.
|
||||
admin(
|
||||
IS_CLOUD
|
||||
? { adminUserIds: [process.env.USER_ADMIN_ID as string] }
|
||||
: { adminRoles: [] },
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -439,6 +465,9 @@ const _auth = {
|
||||
createApiKey: api.createApiKey,
|
||||
registerSSOProvider: api.registerSSOProvider,
|
||||
updateSSOProvider: api.updateSSOProvider,
|
||||
generateSCIMToken: api.generateSCIMToken,
|
||||
listSCIMProviderConnections: api.listSCIMProviderConnections,
|
||||
deleteSCIMProviderConnection: api.deleteSCIMProviderConnection,
|
||||
};
|
||||
|
||||
export type AuthType = typeof _auth;
|
||||
|
||||
1560
pnpm-lock.yaml
generated
1560
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user