Compare commits

...

2 Commits

Author SHA1 Message Date
Mauricio Siu
0dbd1039e8 feat: enhance auth schema and add CLI configuration
- Updated the user schema to include new fields: banned, banReason, and banExpires for improved user management.
- Introduced a new auth-cli configuration file to facilitate database schema generation and inspection for better-auth plugins.
- Ensured the CLI configuration mirrors the plugin set in auth.ts for consistency across the application.
2026-04-19 12:05:37 -06:00
Mauricio Siu
f06c9deddf feat: implement SCIM provisioning support
- Updated dependencies for better-auth packages to version 1.6.5, including api-key, sso, and utils.
- Introduced a new SCIM dialog component for managing SCIM providers and tokens.
- Added SCIM provider management functionality in the backend, including listing, generating tokens, and deleting providers.
- Created a new database table for SCIM providers and updated the schema accordingly.
- Enhanced authentication logic to support SCIM provisioning with enterprise features validation.
2026-04-19 11:59:56 -06:00
16 changed files with 9611 additions and 509 deletions

View File

@@ -0,0 +1,236 @@
"use client";
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 = async (value: string, label: string) => {
try {
await navigator.clipboard.writeText(value);
toast.success(`${label} copied`);
} catch {
toast.error("Failed to copy");
}
};
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>
);
};

View File

@@ -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&apos;s IdP. Users can sign in with their organization&apos;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 ? (

View File

@@ -0,0 +1,11 @@
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 "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

View File

@@ -1163,6 +1163,13 @@
"when": 1775845419261, "when": 1775845419261,
"tag": "0165_abnormal_greymalkin", "tag": "0165_abnormal_greymalkin",
"breakpoints": true "breakpoints": true
},
{
"idx": 166,
"version": "7",
"when": 1776576422440,
"tag": "0166_overjoyed_big_bertha",
"breakpoints": true
} }
] ]
} }

View File

@@ -46,8 +46,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.5",
"@better-auth/sso": "1.5.4", "@better-auth/sso": "1.6.5",
"@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",
@@ -101,7 +101,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.5",
"bl": "6.0.11", "bl": "6.0.11",
"boxen": "^7.1.1", "boxen": "^7.1.1",
"bullmq": "5.67.3", "bullmq": "5.67.3",
@@ -113,7 +113,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",

View File

@@ -31,6 +31,7 @@ import { projectRouter } from "./routers/project";
import { auditLogRouter } from "./routers/proprietary/audit-log"; import { auditLogRouter } from "./routers/proprietary/audit-log";
import { customRoleRouter } from "./routers/proprietary/custom-role"; import { customRoleRouter } from "./routers/proprietary/custom-role";
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";
@@ -93,6 +94,7 @@ export const appRouter = createTRPCRouter({
organization: organizationRouter, organization: organizationRouter,
licenseKey: licenseKeyRouter, licenseKey: licenseKeyRouter,
sso: ssoRouter, sso: ssoRouter,
scim: scimRouter,
whitelabeling: whitelabelingRouter, whitelabeling: whitelabelingRouter,
customRole: customRoleRouter, customRole: customRoleRouter,
auditLog: auditLogRouter, auditLog: auditLogRouter,

View File

@@ -0,0 +1,78 @@
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 };
}),
});

View File

@@ -1,299 +1,311 @@
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { import {
boolean, pgTable,
index, text,
integer, timestamp,
pgTable, boolean,
text, integer,
timestamp, index,
uniqueIndex, uniqueIndex,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
export const user = pgTable("user", { export const user = pgTable("user", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
firstName: text("first_name").notNull(), firstName: text("first_name").notNull(),
email: text("email").notNull().unique(), email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(), emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"), image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at") updatedAt: timestamp("updated_at")
.defaultNow() .defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date()) .$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(), .notNull(),
twoFactorEnabled: boolean("two_factor_enabled").default(false), twoFactorEnabled: boolean("two_factor_enabled").default(false),
role: text("role"), role: text("role"),
ownerId: text("owner_id"), banned: boolean("banned").default(false),
allowImpersonation: boolean("allow_impersonation").default(false), banReason: text("ban_reason"),
lastName: text("last_name").default(""), banExpires: timestamp("ban_expires"),
enableEnterpriseFeatures: boolean("enable_enterprise_features"), ownerId: text("owner_id"),
isValidEnterpriseLicense: boolean("is_valid_enterprise_license"), allowImpersonation: boolean("allow_impersonation").default(false),
lastName: text("last_name").default(""),
enableEnterpriseFeatures: boolean("enable_enterprise_features"),
isValidEnterpriseLicense: boolean("is_valid_enterprise_license"),
}); });
export const session = pgTable( export const session = pgTable(
"session", "session",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(), expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(), token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at") updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date()) .$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(), .notNull(),
ipAddress: text("ip_address"), ipAddress: text("ip_address"),
userAgent: text("user_agent"), userAgent: text("user_agent"),
userId: text("user_id") userId: text("user_id")
.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)],
); );
export const account = pgTable( export const account = pgTable(
"account", "account",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
accountId: text("account_id").notNull(), accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(), providerId: text("provider_id").notNull(),
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"), accessToken: text("access_token"),
refreshToken: text("refresh_token"), refreshToken: text("refresh_token"),
idToken: text("id_token"), idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"), accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"), scope: text("scope"),
password: text("password"), password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at") updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date()) .$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(), .notNull(),
}, },
(table) => [index("account_userId_idx").on(table.userId)], (table) => [index("account_userId_idx").on(table.userId)],
); );
export const verification = pgTable( export const verification = pgTable(
"verification", "verification",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
identifier: text("identifier").notNull(), identifier: text("identifier").notNull(),
value: text("value").notNull(), value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(), expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at") updatedAt: timestamp("updated_at")
.defaultNow() .defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date()) .$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(), .notNull(),
}, },
(table) => [index("verification_identifier_idx").on(table.identifier)], (table) => [index("verification_identifier_idx").on(table.identifier)],
); );
export const apikey = pgTable( export const apikey = pgTable(
"apikey", "apikey",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
configId: text("config_id").default("default").notNull(), configId: text("config_id").default("default").notNull(),
name: text("name"), name: text("name"),
start: text("start"), start: text("start"),
referenceId: text("reference_id").notNull(), referenceId: text("reference_id").notNull(),
prefix: text("prefix"), prefix: text("prefix"),
key: text("key").notNull(), key: text("key").notNull(),
refillInterval: integer("refill_interval"), refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"), refillAmount: integer("refill_amount"),
lastRefillAt: timestamp("last_refill_at"), lastRefillAt: timestamp("last_refill_at"),
enabled: boolean("enabled").default(true), enabled: boolean("enabled").default(true),
rateLimitEnabled: boolean("rate_limit_enabled").default(true), rateLimitEnabled: boolean("rate_limit_enabled").default(true),
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000), rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
rateLimitMax: integer("rate_limit_max").default(10), rateLimitMax: integer("rate_limit_max").default(10),
requestCount: integer("request_count").default(0), requestCount: integer("request_count").default(0),
remaining: integer("remaining"), remaining: integer("remaining"),
lastRequest: timestamp("last_request"), lastRequest: timestamp("last_request"),
expiresAt: timestamp("expires_at"), expiresAt: timestamp("expires_at"),
createdAt: timestamp("created_at").notNull(), createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(), updatedAt: timestamp("updated_at").notNull(),
permissions: text("permissions"), permissions: text("permissions"),
metadata: text("metadata"), metadata: text("metadata"),
}, },
(table) => [ (table) => [
index("apikey_configId_idx").on(table.configId), index("apikey_configId_idx").on(table.configId),
index("apikey_referenceId_idx").on(table.referenceId), index("apikey_referenceId_idx").on(table.referenceId),
index("apikey_key_idx").on(table.key), index("apikey_key_idx").on(table.key),
], ],
); );
export const ssoProvider = pgTable("sso_provider", { export const ssoProvider = pgTable("sso_provider", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
issuer: text("issuer").notNull(), issuer: text("issuer").notNull(),
oidcConfig: text("oidc_config"), oidcConfig: text("oidc_config"),
samlConfig: text("saml_config"), samlConfig: text("saml_config"),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }), userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
providerId: text("provider_id").notNull().unique(), providerId: text("provider_id").notNull().unique(),
organizationId: text("organization_id"), organizationId: text("organization_id"),
domain: text("domain").notNull(), domain: text("domain").notNull(),
}); });
export const twoFactor = pgTable( export const twoFactor = pgTable(
"two_factor", "two_factor",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
secret: text("secret").notNull(), secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(), backupCodes: text("backup_codes").notNull(),
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),
(table) => [ },
index("twoFactor_secret_idx").on(table.secret), (table) => [
index("twoFactor_userId_idx").on(table.userId), index("twoFactor_secret_idx").on(table.secret),
], index("twoFactor_userId_idx").on(table.userId),
],
); );
export const organization = pgTable( export const organization = pgTable(
"organization", "organization",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
name: text("name").notNull(), name: text("name").notNull(),
slug: text("slug").notNull().unique(), slug: text("slug").notNull().unique(),
logo: text("logo"), logo: text("logo"),
createdAt: timestamp("created_at").notNull(), createdAt: timestamp("created_at").notNull(),
metadata: text("metadata"), metadata: text("metadata"),
}, },
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)], (table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
); );
export const organizationRole = pgTable( export const organizationRole = pgTable(
"organization_role", "organization_role",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
organizationId: text("organization_id") organizationId: text("organization_id")
.notNull() .notNull()
.references(() => organization.id, { onDelete: "cascade" }), .references(() => organization.id, { onDelete: "cascade" }),
role: text("role").notNull(), role: text("role").notNull(),
permission: text("permission").notNull(), permission: text("permission").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").$onUpdate( updatedAt: timestamp("updated_at").$onUpdate(
() => /* @__PURE__ */ new Date(), () => /* @__PURE__ */ new Date(),
), ),
}, },
(table) => [ (table) => [
index("organizationRole_organizationId_idx").on(table.organizationId), index("organizationRole_organizationId_idx").on(table.organizationId),
index("organizationRole_role_idx").on(table.role), index("organizationRole_role_idx").on(table.role),
], ],
); );
export const member = pgTable( export const member = pgTable(
"member", "member",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
organizationId: text("organization_id") organizationId: text("organization_id")
.notNull() .notNull()
.references(() => organization.id, { onDelete: "cascade" }), .references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
role: text("role").default("member").notNull(), role: text("role").default("member").notNull(),
createdAt: timestamp("created_at").notNull(), createdAt: timestamp("created_at").notNull(),
}, },
(table) => [ (table) => [
index("member_organizationId_idx").on(table.organizationId), index("member_organizationId_idx").on(table.organizationId),
index("member_userId_idx").on(table.userId), index("member_userId_idx").on(table.userId),
], ],
); );
export const invitation = pgTable( export const invitation = pgTable(
"invitation", "invitation",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
organizationId: text("organization_id") organizationId: text("organization_id")
.notNull() .notNull()
.references(() => organization.id, { onDelete: "cascade" }), .references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(), email: text("email").notNull(),
role: text("role"), role: text("role"),
status: text("status").default("pending").notNull(), status: text("status").default("pending").notNull(),
expiresAt: timestamp("expires_at").notNull(), expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
inviterId: text("inviter_id") inviterId: text("inviter_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
}, },
(table) => [ (table) => [
index("invitation_organizationId_idx").on(table.organizationId), index("invitation_organizationId_idx").on(table.organizationId),
index("invitation_email_idx").on(table.email), index("invitation_email_idx").on(table.email),
], ],
); );
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),
ssoProviders: many(ssoProvider), ssoProviders: many(ssoProvider),
twoFactors: many(twoFactor), twoFactors: many(twoFactor),
members: many(member), members: many(member),
invitations: many(invitation), invitations: many(invitation),
})); }));
export const sessionRelations = relations(session, ({ one }) => ({ export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, { user: one(user, {
fields: [session.userId], fields: [session.userId],
references: [user.id], references: [user.id],
}), }),
})); }));
export const accountRelations = relations(account, ({ one }) => ({ export const accountRelations = relations(account, ({ one }) => ({
user: one(user, { user: one(user, {
fields: [account.userId], fields: [account.userId],
references: [user.id], references: [user.id],
}), }),
})); }));
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({ export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
user: one(user, { user: one(user, {
fields: [ssoProvider.userId], fields: [ssoProvider.userId],
references: [user.id], references: [user.id],
}), }),
})); }));
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({ export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
user: one(user, { user: one(user, {
fields: [twoFactor.userId], fields: [twoFactor.userId],
references: [user.id], references: [user.id],
}), }),
})); }));
export const organizationRelations = relations(organization, ({ many }) => ({ export const organizationRelations = relations(organization, ({ many }) => ({
organizationRoles: many(organizationRole), organizationRoles: many(organizationRole),
members: many(member), members: many(member),
invitations: many(invitation), invitations: many(invitation),
})); }));
export const organizationRoleRelations = relations( export const organizationRoleRelations = relations(
organizationRole, organizationRole,
({ one }) => ({ ({ one }) => ({
organization: one(organization, { organization: one(organization, {
fields: [organizationRole.organizationId], fields: [organizationRole.organizationId],
references: [organization.id], references: [organization.id],
}), }),
}), }),
); );
export const memberRelations = relations(member, ({ one }) => ({ export const memberRelations = relations(member, ({ one }) => ({
organization: one(organization, { organization: one(organization, {
fields: [member.organizationId], fields: [member.organizationId],
references: [organization.id], references: [organization.id],
}), }),
user: one(user, { user: one(user, {
fields: [member.userId], fields: [member.userId],
references: [user.id], references: [user.id],
}), }),
})); }));
export const invitationRelations = relations(invitation, ({ one }) => ({ export const invitationRelations = relations(invitation, ({ one }) => ({
organization: one(organization, { organization: one(organization, {
fields: [invitation.organizationId], fields: [invitation.organizationId],
references: [organization.id], references: [organization.id],
}), }),
user: one(user, { user: one(user, {
fields: [invitation.inviterId], fields: [invitation.inviterId],
references: [user.id], references: [user.id],
}), }),
})); }));

View File

@@ -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.5",
"@better-auth/sso": "1.5.4", "@better-auth/scim": "^1.6.5",
"@better-auth/utils": "0.3.1", "@better-auth/sso": "1.6.5",
"@better-auth/utils": "0.4.0",
"@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.5",
"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",

View File

@@ -214,6 +214,7 @@ 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),
}); });
export const apikey = pgTable("apikey", { export const apikey = pgTable("apikey", {

View File

@@ -30,6 +30,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";

View 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],
}),
}));

View File

@@ -0,0 +1,52 @@
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";
/**
* Minimal better-auth config used only by `@better-auth/cli` to generate /
* inspect database schemas. Must mirror the plugin set in `auth.ts` so the CLI
* sees every table each plugin expects.
*
* Do NOT import this file from the runtime — use `auth.ts` for that.
*/
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(),
],
});

View File

@@ -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";
@@ -178,7 +179,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({
@@ -194,6 +196,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"),
}); });
@@ -229,6 +232,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
@@ -396,7 +403,24 @@ 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,
@@ -442,6 +466,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;

763
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff