feat(scim): implement SCIM 2.0 user provisioning support

This commit is contained in:
Mauricio Siu
2026-07-08 14:06:21 -06:00
parent 995a04d30f
commit d831607f3a
18 changed files with 9730 additions and 906 deletions

View File

@@ -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),

View File

@@ -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",

View File

@@ -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", {

View File

@@ -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";

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

View File

@@ -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";
@@ -180,7 +181,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({
@@ -196,6 +198,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"),
});
@@ -231,6 +234,10 @@ const { handler, api } = betterAuth({
}
}
if (isSCIMRequest) {
return;
}
if (IS_CLOUD || !isAdminPresent) {
await db.transaction(async (tx) => {
const organization = await tx
@@ -398,7 +405,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,
@@ -412,13 +437,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: [] },
),
],
});
@@ -427,6 +453,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;