From 43cf2ddadd53d7b4b8c29456599ea82df40b01fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paola=20Estefan=C3=ADa=20de=20Campos?= <84341268+Paola3stefania@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:40:26 -0300 Subject: [PATCH] fix(sso): enforce domain verification in assignOrganizationByDomain (#6868) --- packages/sso/src/index.ts | 1 + .../sso/src/linking/org-assignment.test.ts | 325 ++++++++++++++++++ packages/sso/src/linking/org-assignment.ts | 15 +- 3 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 packages/sso/src/linking/org-assignment.test.ts diff --git a/packages/sso/src/index.ts b/packages/sso/src/index.ts index 844aa66635..f823956fca 100644 --- a/packages/sso/src/index.ts +++ b/packages/sso/src/index.ts @@ -156,6 +156,7 @@ export function sso(options?: O | undefined): any { await assignOrganizationByDomain(ctx as any, { user: newSession.user, provisioningOptions: options?.organizationProvisioning, + domainVerification: options?.domainVerification, }); }), }, diff --git a/packages/sso/src/linking/org-assignment.test.ts b/packages/sso/src/linking/org-assignment.test.ts new file mode 100644 index 0000000000..c625dd575b --- /dev/null +++ b/packages/sso/src/linking/org-assignment.test.ts @@ -0,0 +1,325 @@ +import type { GenericEndpointContext, User } from "better-auth"; +import { betterAuth } from "better-auth"; +import { memoryAdapter } from "better-auth/adapters/memory"; +import { organization } from "better-auth/plugins"; +import { describe, expect, it } from "vitest"; +import { sso } from ".."; +import { assignOrganizationByDomain } from "./org-assignment"; + +describe("assignOrganizationByDomain", () => { + const createTestContext = () => { + const data = { + user: [] as User[], + session: [] as { id: string }[], + account: [] as { id: string }[], + ssoProvider: [] as { + id: string; + providerId: string; + issuer: string; + domain: string; + domainVerified: boolean; + organizationId: string | null; + userId: string; + }[], + member: [] as { + id: string; + organizationId: string; + userId: string; + role: string; + createdAt: Date; + }[], + organization: [] as { + id: string; + name: string; + slug: string; + createdAt: Date; + }[], + }; + + const memory = memoryAdapter(data); + + const auth = betterAuth({ + database: memory, + baseURL: "http://localhost:3000", + emailAndPassword: { + enabled: true, + }, + plugins: [ + sso({ + domainVerification: { + enabled: true, + }, + }), + organization(), + ], + }); + + const createContext = async () => { + const context = await auth.$context; + return { context } as Partial; + }; + + return { auth, data, createContext }; + }; + + const createUser = (overrides: Partial = {}): User => ({ + id: "user-1", + email: "alice@example.com", + name: "Alice", + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }); + + const createOrg = ( + overrides: Partial<{ id: string; name: string; slug: string }> = {}, + ) => ({ + id: "org-1", + name: "Test Org", + slug: "test-org", + createdAt: new Date(), + ...overrides, + }); + + const createProvider = ( + overrides: Partial<{ + id: string; + providerId: string; + issuer: string; + domain: string; + domainVerified: boolean; + organizationId: string | null; + userId: string; + }> = {}, + ) => ({ + id: "provider-1", + providerId: "test-provider", + issuer: "https://idp.example.com", + domain: "example.com", + domainVerified: false, + organizationId: "org-1" as string | null, + userId: "user-1", + ...overrides, + }); + + it("should NOT assign user to org when provider domain is unverified", async () => { + const { data, createContext } = createTestContext(); + + data.organization.push(createOrg()); + data.ssoProvider.push(createProvider({ domainVerified: false })); + + const user = createUser(); + data.user.push(user); + + const ctx = (await createContext()) as GenericEndpointContext; + await assignOrganizationByDomain(ctx, { + user, + domainVerification: { enabled: true }, + }); + + const members = data.member.filter((m) => m.userId === user.id); + expect(members).toHaveLength(0); + }); + + it("should assign user to org when provider domain is verified", async () => { + const { data, createContext } = createTestContext(); + + const org = createOrg(); + data.organization.push(org); + data.ssoProvider.push( + createProvider({ domainVerified: true, organizationId: org.id }), + ); + + const user = createUser(); + data.user.push(user); + + const ctx = (await createContext()) as GenericEndpointContext; + await assignOrganizationByDomain(ctx, { + user, + domainVerification: { enabled: true }, + }); + + const members = data.member.filter((m) => m.userId === user.id); + expect(members).toHaveLength(1); + expect(members[0]?.organizationId).toBe(org.id); + expect(members[0]?.role).toBe("member"); + }); + + it("should NOT assign user when email domain does not match any provider", async () => { + const { data, createContext } = createTestContext(); + + data.organization.push(createOrg()); + data.ssoProvider.push(createProvider({ domainVerified: true })); + + const user = createUser({ email: "alice@other-domain.com" }); + data.user.push(user); + + const ctx = (await createContext()) as GenericEndpointContext; + await assignOrganizationByDomain(ctx, { + user, + domainVerification: { enabled: true }, + }); + + const members = data.member.filter((m) => m.userId === user.id); + expect(members).toHaveLength(0); + }); + + it("should NOT assign user when provider has no organizationId", async () => { + const { data, createContext } = createTestContext(); + + data.ssoProvider.push( + createProvider({ domainVerified: true, organizationId: null }), + ); + + const user = createUser(); + data.user.push(user); + + const ctx = (await createContext()) as GenericEndpointContext; + await assignOrganizationByDomain(ctx, { + user, + domainVerification: { enabled: true }, + }); + + const members = data.member.filter((m) => m.userId === user.id); + expect(members).toHaveLength(0); + }); + + it("should NOT assign user when provider has no domainVerified field (verification enabled)", async () => { + const { data, createContext } = createTestContext(); + + const org = createOrg(); + data.organization.push(org); + + data.ssoProvider.push({ + id: "provider-1", + providerId: "test-provider", + issuer: "https://idp.example.com", + domain: "example.com", + organizationId: org.id, + userId: "user-1", + } as { + id: string; + providerId: string; + issuer: string; + domain: string; + domainVerified: boolean; + organizationId: string | null; + userId: string; + }); + + const user = createUser(); + data.user.push(user); + + const ctx = (await createContext()) as GenericEndpointContext; + await assignOrganizationByDomain(ctx, { + user, + domainVerification: { enabled: true }, + }); + + const members = data.member.filter((m) => m.userId === user.id); + expect(members).toHaveLength(0); + }); + + it("should assign user when verification is disabled (no domainVerified check)", async () => { + const { data, createContext } = createTestContext(); + + const org = createOrg(); + data.organization.push(org); + data.ssoProvider.push( + createProvider({ domainVerified: false, organizationId: org.id }), + ); + + const user = createUser(); + data.user.push(user); + + const ctx = (await createContext()) as GenericEndpointContext; + await assignOrganizationByDomain(ctx, { + user, + domainVerification: { enabled: false }, + }); + + const members = data.member.filter((m) => m.userId === user.id); + expect(members).toHaveLength(1); + expect(members[0]?.organizationId).toBe(org.id); + }); + + it("should NOT assign user when already a member of the org", async () => { + const { data, createContext } = createTestContext(); + + const org = createOrg(); + data.organization.push(org); + data.ssoProvider.push( + createProvider({ domainVerified: true, organizationId: org.id }), + ); + + const user = createUser(); + data.user.push(user); + + data.member.push({ + id: "member-1", + organizationId: org.id, + userId: user.id, + role: "admin", + createdAt: new Date(), + }); + + const ctx = (await createContext()) as GenericEndpointContext; + await assignOrganizationByDomain(ctx, { + user, + domainVerification: { enabled: true }, + }); + + const members = data.member.filter((m) => m.userId === user.id); + expect(members).toHaveLength(1); + expect(members[0]?.role).toBe("admin"); + }); + + it("should only find verified provider when multiple providers claim same domain", async () => { + const { data, createContext } = createTestContext(); + + const legitOrg = createOrg({ + id: "legit-org", + name: "Legit Org", + slug: "legit-org", + }); + const attackerOrg = createOrg({ + id: "attacker-org", + name: "Attacker Org", + slug: "attacker-org", + }); + data.organization.push(legitOrg, attackerOrg); + + data.ssoProvider.push( + createProvider({ + id: "attacker-provider", + providerId: "attacker-provider", + issuer: "https://attacker.com", + domainVerified: false, + organizationId: attackerOrg.id, + }), + ); + + data.ssoProvider.push( + createProvider({ + id: "legit-provider", + providerId: "legit-provider", + domainVerified: true, + organizationId: legitOrg.id, + }), + ); + + const user = createUser(); + data.user.push(user); + + const ctx = (await createContext()) as GenericEndpointContext; + await assignOrganizationByDomain(ctx, { + user, + domainVerification: { enabled: true }, + }); + + const members = data.member.filter((m) => m.userId === user.id); + expect(members).toHaveLength(1); + expect(members[0]?.organizationId).toBe(legitOrg.id); + }); +}); diff --git a/packages/sso/src/linking/org-assignment.ts b/packages/sso/src/linking/org-assignment.ts index 579d24ca7a..f213d33c3c 100644 --- a/packages/sso/src/linking/org-assignment.ts +++ b/packages/sso/src/linking/org-assignment.ts @@ -100,6 +100,9 @@ export async function assignOrganizationFromProvider( export interface AssignOrganizationByDomainOptions { user: User; provisioningOptions?: OrganizationProvisioningOptions; + domainVerification?: { + enabled?: boolean; + }; } /** @@ -114,7 +117,7 @@ export async function assignOrganizationByDomain( ctx: EndpointContext, options: AssignOrganizationByDomainOptions, ): Promise { - const { user, provisioningOptions } = options; + const { user, provisioningOptions, domainVerification } = options; if (provisioningOptions?.disabled) { return; @@ -133,11 +136,19 @@ export async function assignOrganizationByDomain( return; } + const whereClause: { field: string; value: string | boolean }[] = [ + { field: "domain", value: domain }, + ]; + + if (domainVerification?.enabled) { + whereClause.push({ field: "domainVerified", value: true }); + } + const ssoProvider = await ctx.context.adapter.findOne< SSOProvider >({ model: "ssoProvider", - where: [{ field: "domain", value: domain }], + where: whereClause, }); if (!ssoProvider || !ssoProvider.organizationId) {