mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 23:53:17 -05:00
feat(sso): support multi-domain providers (#7541)
This commit is contained in:
committed by
Alex Yang
parent
ab2c4b9273
commit
5be34214ce
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user