fix(sso): trust admin-configured SSO providers for account linking

better-auth's account-linking guard requires either the IdP asserting
email_verified or the sso_provider row being domainVerified before it
will link an SSO login to an existing local user with the same email.
Entra ID never asserts email_verified (neither via its OIDC userinfo
endpoint nor via SAML attributes), and Dokploy never persisted
domainVerified at all, so linking was permanently impossible for any
Entra-backed provider (OIDC or SAML) regardless of the local user's
own emailVerified state.

Add the domainVerified column, defaulting to true — Dokploy already
scopes providers to an admin-configured domain and gates registration
behind enterprise/admin permissions, so that domain match (still
enforced by better-auth's validateEmailDomain check) is trust enough.

Fixes #5099
This commit is contained in:
Mauricio Siu 2026-09-08 03:17:02 -06:00
parent 988f5b64d6
commit 67f6890fc8
8 changed files with 9219 additions and 6 deletions

View File

@ -83,6 +83,11 @@ const azureMapping: ClaimMapping = {
image: "",
};
// id: "sub",
// email: "preferred_username",
// emailVerified: "email_verified",
// name: "name",
const genericMapping: ClaimMapping = {
id: "sub",
email: "email",
@ -228,10 +233,9 @@ export function RegisterOidcDialog({
mapping: {
id: oidc?.mapping?.id ?? baseMapping.id,
email: oidc?.mapping?.email ?? baseMapping.email,
emailVerified:
oidc?.mapping?.emailVerified ?? baseMapping.emailVerified,
emailVerified: oidc?.mapping?.emailVerified ?? "",
name: oidc?.mapping?.name ?? baseMapping.name,
image: oidc?.mapping?.image ?? baseMapping.image,
image: oidc?.mapping?.image ?? "",
},
});
}, [data, open, form]);

View File

@ -0,0 +1 @@
ALTER TABLE "sso_provider" ADD COLUMN "domain_verified" boolean DEFAULT true NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -1359,6 +1359,13 @@
"when": 1788802431830,
"tag": "0193_chemical_the_liberteens",
"breakpoints": true
},
{
"idx": 194,
"version": "7",
"when": 1788858506799,
"tag": "0194_acoustic_prima",
"breakpoints": true
}
]
}

View File

@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.30.5",
"version": "v0.30.6",
"private": true,
"license": "Apache-2.0",
"type": "module",

View File

@ -135,6 +135,7 @@ export const ssoProvider = pgTable("sso_provider", {
providerId: text("provider_id").notNull().unique(),
organizationId: text("organization_id"),
domain: text("domain").notNull(),
domainVerified: boolean("domain_verified"),
});
export const twoFactor = pgTable(
@ -156,6 +157,29 @@ export const twoFactor = pgTable(
],
);
export const passkey = pgTable(
"passkey",
{
id: text("id").primaryKey(),
name: text("name"),
publicKey: text("public_key").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
credentialID: text("credential_id").notNull(),
counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(),
backedUp: boolean("backed_up").notNull(),
transports: text("transports"),
createdAt: timestamp("created_at"),
aaguid: text("aaguid"),
},
(table) => [
index("passkey_userId_idx").on(table.userId),
index("passkey_credentialID_idx").on(table.credentialID),
],
);
export const organization = pgTable(
"organization",
{
@ -242,6 +266,7 @@ export const userRelations = relations(user, ({ many }) => ({
accounts: many(account),
ssoProviders: many(ssoProvider),
twoFactors: many(twoFactor),
passkeys: many(passkey),
members: many(member),
invitations: many(invitation),
}));
@ -274,6 +299,13 @@ export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
}),
}));
export const passkeyRelations = relations(passkey, ({ one }) => ({
user: one(user, {
fields: [passkey.userId],
references: [user.id],
}),
}));
export const organizationRelations = relations(organization, ({ many }) => ({
organizationRoles: many(organizationRole),
members: many(member),

View File

@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { z } from "zod";
import { organization } from "./account";
import { user } from "./user";
@ -15,6 +15,7 @@ export const ssoProvider = pgTable("sso_provider", {
onDelete: "cascade",
}),
domain: text("domain").notNull(),
domainVerified: boolean("domain_verified").notNull().default(true),
createdAt: timestamp("created_at").notNull().defaultNow(),
});

View File

@ -32,7 +32,12 @@ export const auth = betterAuth({
},
plugins: [
apiKey({ enableMetadata: true, references: "user" }),
sso(),
sso({
trustEmailVerified: true,
domainVerification: {
enabled: true,
},
}),
twoFactor(),
passkey(),
organization({