diff --git a/packages/sso/src/linking/org-assignment.ts b/packages/sso/src/linking/org-assignment.ts index 4850075a1c..5a7bbbf83c 100644 --- a/packages/sso/src/linking/org-assignment.ts +++ b/packages/sso/src/linking/org-assignment.ts @@ -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 - >({ + let ssoProvider = await ctx.context.adapter.findOne>({ 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 + >({ + model: "ssoProvider", + where: domainVerification?.enabled + ? [{ field: "domainVerified", value: true }] + : [], + }); + ssoProvider = + allProviders.find((p) => domainMatches(domain, p.domain)) ?? null; + } + if (!ssoProvider || !ssoProvider.organizationId) { return; } diff --git a/packages/sso/src/routes/sso.ts b/packages/sso/src/routes/sso.ts index 9443ca5e19..dcd9018ec6 100644 --- a/packages/sso/src/routes/sso.ts +++ b/packages/sso/src/routes/sso.ts @@ -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>({ - 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( - res.oidcConfig as unknown as string, - ) || undefined - : undefined, - samlConfig: res.samlConfig - ? safeJsonParse( - res.samlConfig as unknown as string, - ) || undefined - : undefined, - }; - }); + const parseProvider = (res: SSOProvider | null) => { + if (!res) return null; + return { + ...res, + oidcConfig: res.oidcConfig + ? safeJsonParse( + res.oidcConfig as unknown as string, + ) || undefined + : undefined, + samlConfig: res.samlConfig + ? safeJsonParse( + res.samlConfig as unknown as string, + ) || undefined + : undefined, + }; + }; + + if (providerId || orgId) { + // Exact match for providerId or orgId + provider = parseProvider( + await ctx.context.adapter.findOne>({ + 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>({ + 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 + >({ + model: "ssoProvider", + }); + const matchingProvider = allProviders.find((p) => + domainMatches(domain, p.domain), + ); + provider = parseProvider(matchingProvider ?? null); + } + } } if (!provider) { diff --git a/packages/sso/src/utils.test.ts b/packages/sso/src/utils.test.ts new file mode 100644 index 0000000000..238c704bf8 --- /dev/null +++ b/packages/sso/src/utils.test.ts @@ -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); + }); + }); +}); diff --git a/packages/sso/src/utils.ts b/packages/sso/src/utils.ts index 6d465aed82..0867bb95ce 100644 --- a/packages/sso/src/utils.ts +++ b/packages/sso/src/utils.ts @@ -29,13 +29,26 @@ export function safeJsonParse( 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); };