Skip to content

Commit 4af8d42

Browse files
committed
fix(auth): stop trusting IdP email_verified for SSO account linking
Better Auth's link gate is `!isTrustedProvider && !userInfo.emailVerified`, so trustEmailVerified let a true email_verified claim stand in for the domain binding. Any principal able to register an SSO provider — an Enterprise org admin, or any signed-in user when self-hosted — could point it at an IdP they control, assert an arbitrary victim's address as verified, and auto-link into that account across tenant boundaries, persisting as an account row. With it off, linking requires isTrustedProvider, which is domainVerified plus validateEmailDomain(email, provider.domain) — a provider can only claim identities inside the domain it proved. That is the model the codebase already documents for trustProviderByName: false. The option only ever set emailVerified on the local row; it was never what made linking work, since Entra omits the claim and SAML ignores it without a mapping the register contract does not accept.
1 parent 533714b commit 4af8d42

2 files changed

Lines changed: 59 additions & 9 deletions

File tree

apps/sim/lib/auth/auth.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,14 +1169,24 @@ export const auth = betterAuth({
11691169
? [
11701170
sso({
11711171
/**
1172-
* Honor the IdP's `email_verified` claim so the local account is
1173-
* verified rather than forced to false.
1172+
* MUST stay false. Better Auth's link gate is
1173+
* `!isTrustedProvider && !userInfo.emailVerified`, so a true
1174+
* `email_verified` claim substitutes for the domain binding
1175+
* entirely: an IdP could assert any address — including one from a
1176+
* domain it does not own — and auto-link into that user's existing
1177+
* account. Since a provider row can be registered by any Enterprise
1178+
* org admin (and by any signed-in user when self-hosted), trusting
1179+
* the claim makes every account reachable from any tenant's IdP.
11741180
*
1175-
* This is not what enables linking — Entra omits the claim entirely,
1176-
* and SAML ignores it without an explicit `mapping.emailVerified`.
1177-
* `domainVerification` below establishes linking trust.
1181+
* Turning it on only ever set `emailVerified` on the local row; it
1182+
* was never what made linking work. Entra omits the claim, and SAML
1183+
* ignores it without an explicit `mapping.emailVerified` that the
1184+
* register contract does not accept — so SSO users are created
1185+
* unverified either way, and `domainVerification` below is the sole
1186+
* linking trust source, which is what `trustProviderByName: false`
1187+
* already assumes.
11781188
*/
1179-
trustEmailVerified: true,
1189+
trustEmailVerified: false,
11801190
/**
11811191
* Marks a provider authoritative for its domain, which is what lets an
11821192
* SSO sign-in auto-link to an existing same-email account. Without it
@@ -1187,9 +1197,10 @@ export const auth = betterAuth({
11871197
* proven by the `sso_domain` flow before registration, and the register
11881198
* route mirrors that decision onto this flag.
11891199
*
1190-
* It narrows nothing on its own — an IdP asserting `email_verified`
1191-
* links regardless of domain (see `trustEmailVerified` above). It
1192-
* exists so linking survives IdPs that omit the claim.
1200+
* With `trustEmailVerified` off this is the only path to linking, and
1201+
* it is domain-scoped: `isTrustedProvider` additionally requires
1202+
* `validateEmailDomain(userInfo.email, provider.domain)`, so a
1203+
* provider can only ever claim identities inside the domain it proved.
11931204
*/
11941205
domainVerification: { enabled: true },
11951206
organizationProvisioning: {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Locks the SSO linking trust model. Better Auth's account-link gate is
5+
* `!isTrustedProvider && !userInfo.emailVerified`, so a truthy
6+
* `trustEmailVerified` lets any registered IdP assert an out-of-domain address
7+
* as verified and auto-link into that user's account, bypassing the
8+
* domain-verification proof entirely.
9+
*/
10+
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
11+
import { afterAll, expect, it, vi } from 'vitest'
12+
13+
const { ssoOptions } = vi.hoisted(() => ({
14+
ssoOptions: { current: undefined as Record<string, unknown> | undefined },
15+
}))
16+
17+
vi.mock('@better-auth/sso', () => ({
18+
sso: (options: Record<string, unknown>) => {
19+
ssoOptions.current = options
20+
return { id: 'sso' }
21+
},
22+
}))
23+
24+
setEnvFlags({ isSsoEnabled: true })
25+
26+
afterAll(resetEnvFlagsMock)
27+
28+
it('never trusts the IdP-supplied email_verified claim for SSO linking', async () => {
29+
await import('@/lib/auth/auth')
30+
31+
expect(ssoOptions.current).toBeDefined()
32+
expect(ssoOptions.current?.trustEmailVerified).toBe(false)
33+
})
34+
35+
it('keeps domain verification as the sole SSO linking trust source', async () => {
36+
await import('@/lib/auth/auth')
37+
38+
expect(ssoOptions.current?.domainVerification).toEqual({ enabled: true })
39+
})

0 commit comments

Comments
 (0)