diff --git a/apps/sim/lib/auth/sso/application/admit-sso-user.integration.ts b/apps/sim/lib/auth/sso/application/admit-sso-user.integration.ts index 072ba900cc0..43523149b99 100644 --- a/apps/sim/lib/auth/sso/application/admit-sso-user.integration.ts +++ b/apps/sim/lib/auth/sso/application/admit-sso-user.integration.ts @@ -207,6 +207,48 @@ describe('SSO admission with a hosted SCIM directory in PostgreSQL', () => { expect(await membership()).toEqual([{ id: memberId, role: 'admin' }]) }) + async function emailVerified() { + const { db, schema, eq } = runtime + const [row] = await db + .select({ emailVerified: schema.user.emailVerified }) + .from(schema.user) + .where(eq(schema.user.id, userId)) + return row?.emailVerified + } + + it('verifies a directory-provisioned member who signs in through the verified-domain provider', async () => { + const { db, schema, eq } = runtime + const memberId = generateId() + await db.insert(schema.member).values({ id: memberId, userId, organizationId, role: 'member' }) + await db.update(schema.user).set({ emailVerified: false }).where(eq(schema.user.id, userId)) + + await expect(admit()).resolves.toEqual({ kind: 'already-member', organizationId, memberId }) + expect(await emailVerified()).toBe(true) + }) + + it('leaves an address outside the provider domain unverified', async () => { + const { db, schema, eq } = runtime + await db + .update(schema.user) + .set({ emailVerified: false, email: `${userId}@elsewhere.test` }) + .where(eq(schema.user.id, userId)) + + await expect(admit()).resolves.toEqual({ kind: 'denied', reason: 'domain-mismatch' }) + expect(await emailVerified()).toBe(false) + }) + + it('leaves the address unverified when the provider domain is not proven', async () => { + const { db, schema, eq } = runtime + await db.update(schema.user).set({ emailVerified: false }).where(eq(schema.user.id, userId)) + await db + .update(schema.ssoProvider) + .set({ domainVerified: false }) + .where(eq(schema.ssoProvider.providerId, providerId)) + + await expect(admit()).resolves.toEqual({ kind: 'denied', reason: 'provider-not-trusted' }) + expect(await emailVerified()).toBe(false) + }) + it('resumes normal JIT admission when the directory permits automatic membership', async () => { const { db, schema, eq } = runtime await db diff --git a/apps/sim/lib/auth/sso/application/admit-sso-user.ts b/apps/sim/lib/auth/sso/application/admit-sso-user.ts index 8e97f7fda49..011dbb51f67 100644 --- a/apps/sim/lib/auth/sso/application/admit-sso-user.ts +++ b/apps/sim/lib/auth/sso/application/admit-sso-user.ts @@ -110,7 +110,12 @@ async function runAdmissionTransaction( const [[userRow], [linkedAccount]] = await Promise.all([ tx - .select({ id: user.id, email: user.email, name: user.name }) + .select({ + id: user.id, + email: user.email, + name: user.name, + emailVerified: user.emailVerified, + }) .from(user) .where(eq(user.id, userId)) .limit(1), @@ -133,6 +138,19 @@ async function runAdmissionTransaction( return { providerId, result: { kind: 'denied', reason: 'domain-mismatch' } } } + /** + * `trustEmailVerified` stays off, so Better Auth always stores an SSO user as + * unverified. Signing in through a linked provider on a DNS-verified domain + * that matches the address is the ownership proof, and without it SSO- and + * SCIM-created users can never connect accounts. + */ + if (!userRow.emailVerified) { + await tx + .update(user) + .set({ emailVerified: true, updatedAt: new Date() }) + .where(and(eq(user.id, userId), eq(user.email, userRow.email))) + } + const attribution = { providerId, userName: userRow.name, userEmail: userRow.email } if (!provider.organizationId) { return {