mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-07 12:32:16 +00:00
feat(scim): implement SCIM 2.0 user provisioning support
This commit is contained in:
@@ -97,6 +97,7 @@ export const ShowUsers = () => {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[100px]">Email</TableHead>
|
<TableHead className="w-[100px]">Email</TableHead>
|
||||||
<TableHead className="text-center">Role</TableHead>
|
<TableHead className="text-center">Role</TableHead>
|
||||||
|
<TableHead className="text-center">Status</TableHead>
|
||||||
<TableHead className="text-center">2FA</TableHead>
|
<TableHead className="text-center">2FA</TableHead>
|
||||||
|
|
||||||
<TableHead className="text-center">
|
<TableHead className="text-center">
|
||||||
@@ -173,6 +174,17 @@ export const ShowUsers = () => {
|
|||||||
{member.role}
|
{member.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
member.user.banned
|
||||||
|
? "destructive"
|
||||||
|
: "outline"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{member.user.banned ? "Deactivated" : "Active"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
{member.user.twoFactorEnabled
|
{member.user.twoFactorEnabled
|
||||||
? "Enabled"
|
? "Enabled"
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
Eye,
|
Eye,
|
||||||
|
KeyRound,
|
||||||
Loader2,
|
Loader2,
|
||||||
LogIn,
|
LogIn,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -34,6 +35,7 @@ import { api } from "@/utils/api";
|
|||||||
import { useUrl } from "@/utils/hooks/use-url";
|
import { useUrl } from "@/utils/hooks/use-url";
|
||||||
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
||||||
import { RegisterSamlDialog } from "./register-saml-dialog";
|
import { RegisterSamlDialog } from "./register-saml-dialog";
|
||||||
|
import { ScimDialog } from "./scim-dialog";
|
||||||
|
|
||||||
type ProviderForDetails = {
|
type ProviderForDetails = {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
@@ -169,15 +171,22 @@ export const SSOSettings = () => {
|
|||||||
Users can sign in with their organization's IdP.
|
Users can sign in with their organization's IdP.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex flex-wrap gap-2 shrink-0">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => setManageOriginsOpen(true)}
|
size="sm"
|
||||||
className="shrink-0"
|
onClick={() => setManageOriginsOpen(true)}
|
||||||
>
|
>
|
||||||
<Shield className="mr-2 size-4" />
|
<Shield className="mr-2 size-4" />
|
||||||
Manage origins
|
Manage origins
|
||||||
</Button>
|
</Button>
|
||||||
|
<ScimDialog>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<KeyRound className="mr-2 size-4" />
|
||||||
|
Manage SCIM
|
||||||
|
</Button>
|
||||||
|
</ScimDialog>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
|
|||||||
@@ -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;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1212,6 +1212,13 @@
|
|||||||
"when": 1781045439162,
|
"when": 1781045439162,
|
||||||
"tag": "0172_quick_the_professor",
|
"tag": "0172_quick_the_professor",
|
||||||
"breakpoints": true
|
"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/mistral": "^3.0.20",
|
||||||
"@ai-sdk/openai": "^3.0.29",
|
"@ai-sdk/openai": "^3.0.29",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||||
"@better-auth/api-key": "1.5.4",
|
"@better-auth/api-key": "1.6.23",
|
||||||
"@better-auth/sso": "1.5.4",
|
"@better-auth/sso": "1.6.23",
|
||||||
"@codemirror/autocomplete": "^6.18.6",
|
"@codemirror/autocomplete": "^6.18.6",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
"ai": "^6.0.86",
|
"ai": "^6.0.86",
|
||||||
"ai-sdk-ollama": "^3.7.0",
|
"ai-sdk-ollama": "^3.7.0",
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
"better-auth": "1.5.4",
|
"better-auth": "1.6.23",
|
||||||
"bl": "6.0.11",
|
"bl": "6.0.11",
|
||||||
"boxen": "^7.1.1",
|
"boxen": "^7.1.1",
|
||||||
"bullmq": "5.67.3",
|
"bullmq": "5.67.3",
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
"dockerode": "4.0.2",
|
"dockerode": "4.0.2",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.2",
|
||||||
"drizzle-zod": "0.8.3",
|
"drizzle-zod": "0.8.3",
|
||||||
"fancy-ansi": "^0.1.3",
|
"fancy-ansi": "^0.1.3",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { auditLogRouter } from "./routers/proprietary/audit-log";
|
|||||||
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
||||||
import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
|
import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
|
||||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||||
|
import { scimRouter } from "./routers/proprietary/scim";
|
||||||
import { ssoRouter } from "./routers/proprietary/sso";
|
import { ssoRouter } from "./routers/proprietary/sso";
|
||||||
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||||
import { redirectsRouter } from "./routers/redirects";
|
import { redirectsRouter } from "./routers/redirects";
|
||||||
@@ -94,6 +95,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
organization: organizationRouter,
|
organization: organizationRouter,
|
||||||
licenseKey: licenseKeyRouter,
|
licenseKey: licenseKeyRouter,
|
||||||
sso: ssoRouter,
|
sso: ssoRouter,
|
||||||
|
scim: scimRouter,
|
||||||
forwardAuth: forwardAuthRouter,
|
forwardAuth: forwardAuthRouter,
|
||||||
whitelabeling: whitelabelingRouter,
|
whitelabeling: whitelabelingRouter,
|
||||||
customRole: customRoleRouter,
|
customRole: customRoleRouter,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
});
|
||||||
+3
-1
@@ -46,7 +46,9 @@
|
|||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"esbuild": "0.20.2"
|
"esbuild": "0.20.2",
|
||||||
|
"better-call": "1.3.7",
|
||||||
|
"@better-fetch/fetch": "1.3.1"
|
||||||
},
|
},
|
||||||
"peerDependencyRules": {
|
"peerDependencyRules": {
|
||||||
"ignoreMissing": [
|
"ignoreMissing": [
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export const user = pgTable("user", {
|
|||||||
.notNull(),
|
.notNull(),
|
||||||
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||||
role: text("role"),
|
role: text("role"),
|
||||||
|
banned: boolean("banned").default(false),
|
||||||
|
banReason: text("ban_reason"),
|
||||||
|
banExpires: timestamp("ban_expires"),
|
||||||
ownerId: text("owner_id"),
|
ownerId: text("owner_id"),
|
||||||
allowImpersonation: boolean("allow_impersonation").default(false),
|
allowImpersonation: boolean("allow_impersonation").default(false),
|
||||||
lastName: text("last_name").default(""),
|
lastName: text("last_name").default(""),
|
||||||
@@ -45,6 +48,7 @@ export const session = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
activeOrganizationId: text("active_organization_id"),
|
activeOrganizationId: text("active_organization_id"),
|
||||||
|
impersonatedBy: text("impersonated_by"),
|
||||||
},
|
},
|
||||||
(table) => [index("session_userId_idx").on(table.userId)],
|
(table) => [index("session_userId_idx").on(table.userId)],
|
||||||
);
|
);
|
||||||
@@ -142,6 +146,9 @@ export const twoFactor = pgTable(
|
|||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
verified: boolean("verified").default(true),
|
||||||
|
failedVerificationCount: integer("failed_verification_count").default(0),
|
||||||
|
lockedUntil: timestamp("locked_until"),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("twoFactor_secret_idx").on(table.secret),
|
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 }) => ({
|
export const userRelations = relations(user, ({ many }) => ({
|
||||||
sessions: many(session),
|
sessions: many(session),
|
||||||
accounts: many(account),
|
accounts: many(account),
|
||||||
|
|||||||
@@ -37,9 +37,10 @@
|
|||||||
"@ai-sdk/mistral": "^3.0.20",
|
"@ai-sdk/mistral": "^3.0.20",
|
||||||
"@ai-sdk/openai": "^3.0.29",
|
"@ai-sdk/openai": "^3.0.29",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||||
"@better-auth/api-key": "1.5.4",
|
"@better-auth/api-key": "1.6.23",
|
||||||
"@better-auth/sso": "1.5.4",
|
"@better-auth/scim": "1.6.23",
|
||||||
"@better-auth/utils": "0.3.1",
|
"@better-auth/sso": "1.6.23",
|
||||||
|
"@better-auth/utils": "0.4.2",
|
||||||
"@faker-js/faker": "^8.4.1",
|
"@faker-js/faker": "^8.4.1",
|
||||||
"@octokit/auth-app": "^6.1.3",
|
"@octokit/auth-app": "^6.1.3",
|
||||||
"@octokit/rest": "^20.1.2",
|
"@octokit/rest": "^20.1.2",
|
||||||
@@ -51,15 +52,14 @@
|
|||||||
"ai": "^6.0.86",
|
"ai": "^6.0.86",
|
||||||
"ai-sdk-ollama": "^3.7.0",
|
"ai-sdk-ollama": "^3.7.0",
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
"better-auth": "1.5.4",
|
"better-auth": "1.6.23",
|
||||||
"better-call": "2.0.2",
|
|
||||||
"bl": "6.0.11",
|
"bl": "6.0.11",
|
||||||
"boxen": "^7.1.1",
|
"boxen": "^7.1.1",
|
||||||
"date-fns": "3.6.0",
|
"date-fns": "3.6.0",
|
||||||
"dockerode": "4.0.2",
|
"dockerode": "4.0.2",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
"drizzle-dbml-generator": "0.10.0",
|
"drizzle-dbml-generator": "0.10.0",
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.2",
|
||||||
"drizzle-zod": "0.5.1",
|
"drizzle-zod": "0.5.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
@@ -87,7 +87,6 @@
|
|||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@better-auth/cli": "1.4.21",
|
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
"@types/dockerode": "3.3.23",
|
"@types/dockerode": "3.3.23",
|
||||||
|
|||||||
@@ -214,6 +214,11 @@ export const twoFactor = pgTable("two_factor", {
|
|||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.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", {
|
export const apikey = pgTable("apikey", {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export * from "./redis";
|
|||||||
export * from "./registry";
|
export * from "./registry";
|
||||||
export * from "./rollbacks";
|
export * from "./rollbacks";
|
||||||
export * from "./schedule";
|
export * from "./schedule";
|
||||||
|
export * from "./scim";
|
||||||
export * from "./security";
|
export * from "./security";
|
||||||
export * from "./server";
|
export * from "./server";
|
||||||
export * from "./session";
|
export * from "./session";
|
||||||
|
|||||||
@@ -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],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -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 type { IncomingMessage } from "node:http";
|
||||||
import { apiKey } from "@better-auth/api-key";
|
import { apiKey } from "@better-auth/api-key";
|
||||||
|
import { scim } from "@better-auth/scim";
|
||||||
import { sso } from "@better-auth/sso";
|
import { sso } from "@better-auth/sso";
|
||||||
import * as bcrypt from "bcrypt";
|
import * as bcrypt from "bcrypt";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
@@ -180,7 +181,8 @@ const { handler, api } = betterAuth({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const isSSORequest = context?.path.includes("/sso");
|
const isSSORequest = context?.path.includes("/sso");
|
||||||
if (isSSORequest) {
|
const isSCIMRequest = context?.path.includes("/scim");
|
||||||
|
if (isSSORequest || isSCIMRequest) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const isAdminPresent = await db.query.member.findFirst({
|
const isAdminPresent = await db.query.member.findFirst({
|
||||||
@@ -196,6 +198,7 @@ const { handler, api } = betterAuth({
|
|||||||
},
|
},
|
||||||
after: async (user, context) => {
|
after: async (user, context) => {
|
||||||
const isSSORequest = context?.path.includes("/sso");
|
const isSSORequest = context?.path.includes("/sso");
|
||||||
|
const isSCIMRequest = context?.path.includes("/scim");
|
||||||
const isAdminPresent = await db.query.member.findFirst({
|
const isAdminPresent = await db.query.member.findFirst({
|
||||||
where: eq(schema.member.role, "owner"),
|
where: eq(schema.member.role, "owner"),
|
||||||
});
|
});
|
||||||
@@ -231,6 +234,10 @@ const { handler, api } = betterAuth({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSCIMRequest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (IS_CLOUD || !isAdminPresent) {
|
if (IS_CLOUD || !isAdminPresent) {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
const organization = await tx
|
const organization = await tx
|
||||||
@@ -398,7 +405,25 @@ const { handler, api } = betterAuth({
|
|||||||
enableMetadata: true,
|
enableMetadata: true,
|
||||||
references: "user",
|
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(),
|
twoFactor(),
|
||||||
organization({
|
organization({
|
||||||
ac,
|
ac,
|
||||||
@@ -412,13 +437,14 @@ const { handler, api } = betterAuth({
|
|||||||
maximumRolesPerOrganization: 10,
|
maximumRolesPerOrganization: 10,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
...(IS_CLOUD
|
// Self-hosted needs the admin plugin too: SCIM deactivation (active: false)
|
||||||
? [
|
// maps to the admin plugin's `banned` field and is rejected without it.
|
||||||
admin({
|
// adminRoles: [] keeps every /admin/* endpoint locked on self-hosted.
|
||||||
adminUserIds: [process.env.USER_ADMIN_ID as string],
|
admin(
|
||||||
}),
|
IS_CLOUD
|
||||||
]
|
? { adminUserIds: [process.env.USER_ADMIN_ID as string] }
|
||||||
: []),
|
: { adminRoles: [] },
|
||||||
|
),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -427,6 +453,9 @@ const _auth = {
|
|||||||
createApiKey: api.createApiKey,
|
createApiKey: api.createApiKey,
|
||||||
registerSSOProvider: api.registerSSOProvider,
|
registerSSOProvider: api.registerSSOProvider,
|
||||||
updateSSOProvider: api.updateSSOProvider,
|
updateSSOProvider: api.updateSSOProvider,
|
||||||
|
generateSCIMToken: api.generateSCIMToken,
|
||||||
|
listSCIMProviderConnections: api.listSCIMProviderConnections,
|
||||||
|
deleteSCIMProviderConnection: api.deleteSCIMProviderConnection,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthType = typeof _auth;
|
export type AuthType = typeof _auth;
|
||||||
|
|||||||
Generated
+684
-876
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user