feat(sso): support multi-domain providers (#7541)

This commit is contained in:
Paola Estefanía de Campos
2026-01-28 17:06:03 -08:00
committed by Alex Yang
parent ab2c4b9273
commit 5be34214ce
4 changed files with 194 additions and 42 deletions
+18 -3
View File
@@ -1,5 +1,6 @@
import type { GenericEndpointContext, OAuth2Tokens, User } from "better-auth";
import type { SSOOptions, SSOProvider } from "../types";
import { domainMatches } from "../utils";
import type { NormalizedSSOProfile } from "./types";
export interface OrganizationProvisioningOptions {
@@ -118,6 +119,8 @@ export async function assignOrganizationByDomain(
return;
}
// Support comma-separated domains for multi-domain SSO
// First try exact match (fast path)
const whereClause: { field: string; value: string | boolean }[] = [
{ field: "domain", value: domain },
];
@@ -126,13 +129,25 @@ export async function assignOrganizationByDomain(
whereClause.push({ field: "domainVerified", value: true });
}
const ssoProvider = await ctx.context.adapter.findOne<
SSOProvider<SSOOptions>
>({
let ssoProvider = await ctx.context.adapter.findOne<SSOProvider<SSOOptions>>({
model: "ssoProvider",
where: whereClause,
});
// If not found, search all providers for comma-separated domain match
if (!ssoProvider) {
const allProviders = await ctx.context.adapter.findMany<
SSOProvider<SSOOptions>
>({
model: "ssoProvider",
where: domainVerification?.enabled
? [{ field: "domainVerified", value: true }]
: [],
});
ssoProvider =
allProviders.find((p) => domainMatches(domain, p.domain)) ?? null;
}
if (!ssoProvider || !ssoProvider.organizationId) {
return;
}
+55 -34
View File
@@ -53,7 +53,7 @@ import {
validateSingleAssertion,
} from "../saml";
import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
import { safeJsonParse, validateEmailDomain } from "../utils";
import { domainMatches, safeJsonParse, validateEmailDomain } from "../utils";
export interface TimestampValidationOptions {
clockSkew?: number;
@@ -247,7 +247,8 @@ const ssoProviderBodySchema = z.object({
description: "The issuer of the provider",
}),
domain: z.string({}).meta({
description: "The domain of the provider. This is used for email matching",
description:
"The domain(s) of the provider. For enterprise multi-domain SSO where a single IdP serves multiple email domains, use comma-separated values (e.g., 'company.com,subsidiary.com,acquired-company.com')",
}),
oidcConfig: z
.object({
@@ -1121,38 +1122,58 @@ export const signInSSO = (options?: SSOOptions) => {
}
// Try to find provider in database
if (!provider) {
provider = await ctx.context.adapter
.findOne<SSOProvider<SSOOptions>>({
model: "ssoProvider",
where: [
{
field: providerId
? "providerId"
: orgId
? "organizationId"
: "domain",
value: providerId || orgId || domain!,
},
],
})
.then((res) => {
if (!res) {
return null;
}
return {
...res,
oidcConfig: res.oidcConfig
? safeJsonParse<OIDCConfig>(
res.oidcConfig as unknown as string,
) || undefined
: undefined,
samlConfig: res.samlConfig
? safeJsonParse<SAMLConfig>(
res.samlConfig as unknown as string,
) || undefined
: undefined,
};
});
const parseProvider = (res: SSOProvider<SSOOptions> | null) => {
if (!res) return null;
return {
...res,
oidcConfig: res.oidcConfig
? safeJsonParse<OIDCConfig>(
res.oidcConfig as unknown as string,
) || undefined
: undefined,
samlConfig: res.samlConfig
? safeJsonParse<SAMLConfig>(
res.samlConfig as unknown as string,
) || undefined
: undefined,
};
};
if (providerId || orgId) {
// Exact match for providerId or orgId
provider = parseProvider(
await ctx.context.adapter.findOne<SSOProvider<SSOOptions>>({
model: "ssoProvider",
where: [
{
field: providerId ? "providerId" : "organizationId",
value: providerId || orgId!,
},
],
}),
);
} else if (domain) {
// For domain lookup, support comma-separated domains
// First try exact match (fast path)
provider = parseProvider(
await ctx.context.adapter.findOne<SSOProvider<SSOOptions>>({
model: "ssoProvider",
where: [{ field: "domain", value: domain }],
}),
);
// If not found, search all providers for comma-separated domain match
if (!provider) {
const allProviders = await ctx.context.adapter.findMany<
SSOProvider<SSOOptions>
>({
model: "ssoProvider",
});
const matchingProvider = allProviders.find((p) =>
domainMatches(domain, p.domain),
);
provider = parseProvider(matchingProvider ?? null);
}
}
}
if (!provider) {
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { validateEmailDomain } from "./utils";
describe("validateEmailDomain", () => {
// Tests for issue #7324: Enterprise multi-domain SSO support
// https://github.com/better-auth/better-auth/issues/7324
describe("single domain", () => {
it("should validate email matches domain exactly", () => {
expect(validateEmailDomain("user@company.com", "company.com")).toBe(true);
});
it("should validate email matches subdomain", () => {
expect(validateEmailDomain("user@hr.company.com", "company.com")).toBe(
true,
);
expect(
validateEmailDomain("user@dept.hr.company.com", "company.com"),
).toBe(true);
});
it("should reject email from different domain", () => {
expect(validateEmailDomain("user@other.com", "company.com")).toBe(false);
});
it("should reject email where domain is a suffix but not subdomain", () => {
// "notcompany.com" should not match "company.com"
expect(validateEmailDomain("user@notcompany.com", "company.com")).toBe(
false,
);
});
it("should be case-insensitive", () => {
expect(validateEmailDomain("USER@COMPANY.COM", "company.com")).toBe(true);
expect(validateEmailDomain("user@company.com", "COMPANY.COM")).toBe(true);
});
});
describe("multiple domains (enterprise multi-domain SSO)", () => {
// Issue #7324: Single IdP (e.g., Okta) serving multiple email domains
it("should validate email against any domain in comma-separated list", () => {
const domains = "company.com,subsidiary.com,acquired-company.com";
expect(validateEmailDomain("user@company.com", domains)).toBe(true);
expect(validateEmailDomain("user@subsidiary.com", domains)).toBe(true);
expect(validateEmailDomain("user@acquired-company.com", domains)).toBe(
true,
);
});
it("should validate subdomains for any domain in the list", () => {
const domains = "company.com,subsidiary.com";
expect(validateEmailDomain("user@hr.company.com", domains)).toBe(true);
expect(validateEmailDomain("user@dept.subsidiary.com", domains)).toBe(
true,
);
});
it("should reject email not matching any domain", () => {
const domains = "company.com,subsidiary.com,acquired-company.com";
expect(validateEmailDomain("user@other.com", domains)).toBe(false);
expect(validateEmailDomain("user@notcompany.com", domains)).toBe(false);
});
it("should handle whitespace in domain list", () => {
const domains = "company.com, subsidiary.com , acquired-company.com";
expect(validateEmailDomain("user@company.com", domains)).toBe(true);
expect(validateEmailDomain("user@subsidiary.com", domains)).toBe(true);
expect(validateEmailDomain("user@acquired-company.com", domains)).toBe(
true,
);
});
it("should handle empty domains in list gracefully", () => {
const domains = "company.com,,subsidiary.com";
expect(validateEmailDomain("user@company.com", domains)).toBe(true);
expect(validateEmailDomain("user@subsidiary.com", domains)).toBe(true);
});
it("should be case-insensitive for multiple domains", () => {
const domains = "Company.COM,SUBSIDIARY.com";
expect(validateEmailDomain("user@company.com", domains)).toBe(true);
expect(validateEmailDomain("USER@SUBSIDIARY.COM", domains)).toBe(true);
});
});
describe("edge cases", () => {
it("should return false for empty email", () => {
expect(validateEmailDomain("", "company.com")).toBe(false);
});
it("should return false for empty domain", () => {
expect(validateEmailDomain("user@company.com", "")).toBe(false);
});
it("should return false for email without @", () => {
expect(validateEmailDomain("usercompany.com", "company.com")).toBe(false);
});
it("should return false for domain list with only whitespace/commas", () => {
expect(validateEmailDomain("user@company.com", ", ,")).toBe(false);
});
});
});
+18 -5
View File
@@ -29,13 +29,26 @@ export function safeJsonParse<T>(
return null;
}
/**
* Checks if a domain matches any domain in a comma-separated list.
*/
export const domainMatches = (searchDomain: string, domainList: string) => {
const search = searchDomain.toLowerCase();
const domains = domainList
.split(",")
.map((d) => d.trim().toLowerCase())
.filter(Boolean);
return domains.some((d) => search === d || search.endsWith(`.${d}`));
};
/**
* Validates email domain against allowed domain(s).
* Supports comma-separated domains for multi-domain SSO.
*/
export const validateEmailDomain = (email: string, domain: string) => {
const emailDomain = email.split("@")[1]?.toLowerCase();
const providerDomain = domain.toLowerCase();
if (!emailDomain || !providerDomain) {
if (!emailDomain || !domain) {
return false;
}
return (
emailDomain === providerDomain || emailDomain.endsWith(`.${providerDomain}`)
);
return domainMatches(emailDomain, domain);
};