Skip to content

Commit 77f520c

Browse files
authored
fix(auth): scope SSO account linking to the verified domain and fence plugin provider mutations (#6738)
* fix(auth): fence off plugin-served SSO provider mutation endpoints The auth catch-all forwarded every non-organization POST to the better-auth SSO plugin, leaving sso/update-provider and sso/delete-provider reachable alongside the app-owned sso/register route. update-provider is gated only on provider ownership and merges the caller's samlConfig, so a provider owner could set mapping.emailVerified — a field the register contract deliberately omits and the plugin's identity-boundary guard does not inspect, so it never trips the linked-account conflict. With trustEmailVerified enabled, a subsequent assertion carrying an arbitrary verified email auto-links to that user's account. Block SSO POST paths by default, allowing only the sso/saml2/ protocol endpoints the IdP posts to, mirroring the existing organization fence. * 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. * docs(auth): note that the SSO fence and trustEmailVerified are layered * test(auth): drop unnecessary any casts from the auth catch-all tests createMockRequest already returns a NextRequest and the handler mocks are untyped vi.fn()s, so every cast in the file was suppressing type checking for no reason.
1 parent b96c053 commit 77f520c

4 files changed

Lines changed: 171 additions & 15 deletions

File tree

apps/sim/app/api/auth/[...all]/route.test.ts

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ describe('auth catch-all route (DISABLE_AUTH get-session)', () => {
5050
'http://localhost:3000/api/auth/get-session'
5151
)
5252

53-
const res = await GET(req as any)
53+
const res = await GET(req)
5454
const json = await res.json()
5555

5656
expect(handlerMocks.ensureAnonymousUserExists).toHaveBeenCalledTimes(1)
@@ -68,7 +68,7 @@ describe('auth catch-all route (DISABLE_AUTH get-session)', () => {
6868
handlerMocks.betterAuthGET.mockResolvedValueOnce(
6969
new NextResponse(JSON.stringify({ data: { ok: true } }), {
7070
headers: { 'content-type': 'application/json' },
71-
}) as any
71+
})
7272
)
7373

7474
const req = createMockRequest(
@@ -78,7 +78,7 @@ describe('auth catch-all route (DISABLE_AUTH get-session)', () => {
7878
'http://localhost:3000/api/auth/get-session'
7979
)
8080

81-
const res = await GET(req as any)
81+
const res = await GET(req)
8282
const json = await res.json()
8383

8484
expect(handlerMocks.ensureAnonymousUserExists).not.toHaveBeenCalled()
@@ -100,7 +100,7 @@ describe('auth catch-all route organization mutations', () => {
100100
'http://localhost:3000/api/auth/organization/create'
101101
)
102102

103-
const res = await POST(req as any)
103+
const res = await POST(req)
104104
const json = await res.json()
105105

106106
expect(res.status).toBe(404)
@@ -115,7 +115,7 @@ describe('auth catch-all route organization mutations', () => {
115115
handlerMocks.betterAuthPOST.mockResolvedValueOnce(
116116
new NextResponse(JSON.stringify({ data: { ok: true } }), {
117117
headers: { 'content-type': 'application/json' },
118-
}) as any
118+
})
119119
)
120120

121121
const req = createMockRequest(
@@ -125,10 +125,77 @@ describe('auth catch-all route organization mutations', () => {
125125
'http://localhost:3000/api/auth/organization/set-active'
126126
)
127127

128-
const res = await POST(req as any)
128+
const res = await POST(req)
129129
const json = await res.json()
130130

131131
expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1)
132132
expect(json).toEqual({ data: { ok: true } })
133133
})
134134
})
135+
136+
describe('auth catch-all route SSO provider mutations', () => {
137+
beforeEach(() => {
138+
vi.clearAllMocks()
139+
})
140+
141+
it.each([
142+
'sso/update-provider',
143+
'sso/delete-provider',
144+
'sso/request-domain-verification',
145+
'sso/verify-domain',
146+
])('blocks the plugin-served %s endpoint', async (path) => {
147+
const req = createMockRequest('POST', undefined, {}, `http://localhost:3000/api/auth/${path}`)
148+
149+
const res = await POST(req)
150+
const json = await res.json()
151+
152+
expect(res.status).toBe(404)
153+
expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled()
154+
expect(json).toEqual({
155+
error: 'SSO provider mutations are handled by application API routes.',
156+
})
157+
})
158+
159+
it.each([
160+
'sso/saml2/callback/acme',
161+
'sso/saml2/sp/acs/acme',
162+
'sso/saml2/sp/slo/acme',
163+
'sso/saml2/logout/acme',
164+
])('allows the SAML protocol endpoint %s', async (path) => {
165+
const { NextResponse } = await import('next/server')
166+
handlerMocks.betterAuthPOST.mockResolvedValueOnce(
167+
new NextResponse(JSON.stringify({ data: { ok: true } }), {
168+
headers: { 'content-type': 'application/json' },
169+
})
170+
)
171+
172+
const req = createMockRequest('POST', undefined, {}, `http://localhost:3000/api/auth/${path}`)
173+
174+
const res = await POST(req)
175+
const json = await res.json()
176+
177+
expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1)
178+
expect(json).toEqual({ data: { ok: true } })
179+
})
180+
181+
it('leaves the SSO sign-in endpoint reachable', async () => {
182+
const { NextResponse } = await import('next/server')
183+
handlerMocks.betterAuthPOST.mockResolvedValueOnce(
184+
new NextResponse(JSON.stringify({ data: { url: 'https://idp.example.com' } }), {
185+
headers: { 'content-type': 'application/json' },
186+
})
187+
)
188+
189+
const req = createMockRequest(
190+
'POST',
191+
undefined,
192+
{},
193+
'http://localhost:3000/api/auth/sign-in/sso'
194+
)
195+
196+
const res = await POST(req)
197+
198+
expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1)
199+
expect(await res.json()).toEqual({ data: { url: 'https://idp.example.com' } })
200+
})
201+
})

apps/sim/app/api/auth/[...all]/route.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ export const dynamic = 'force-dynamic'
1010
const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler)
1111
const SAFE_ORGANIZATION_POST_PATHS = new Set(['organization/check-slug', 'organization/set-active'])
1212

13+
/**
14+
* SAML protocol endpoints the IdP posts to (`saml2/callback/:id`,
15+
* `saml2/sp/acs/:id`, `saml2/sp/slo/:id`, `saml2/logout/:id`). These are the
16+
* only SSO paths the plugin must keep serving on POST — every other SSO POST
17+
* endpoint it registers is a provider mutation.
18+
*/
19+
const SAML_PROTOCOL_POST_PREFIX = 'sso/saml2/'
20+
1321
function getAuthPath(request: NextRequest): string {
1422
const pathname = request.nextUrl?.pathname ?? new URL(request.url).pathname
1523
return pathname.replace('/api/auth/', '')
@@ -19,6 +27,30 @@ function isBlockedOrganizationMutationPath(path: string): boolean {
1927
return path.startsWith('organization/') && !SAFE_ORGANIZATION_POST_PATHS.has(path)
2028
}
2129

30+
/**
31+
* SSO provider configuration is owned by `/api/auth/sso/register`, which proves
32+
* domain ownership before granting trust and restricts the attribute mapping to
33+
* `id`/`email`/`name`/`image`. The plugin's own `sso/update-provider` bypasses
34+
* both: it is gated only on provider ownership and merges the caller's config,
35+
* so a provider owner could add `mapping.emailVerified` — a change the plugin's
36+
* identity-boundary guard does not consider, so it never trips the linked-account
37+
* conflict — and then assert an arbitrary victim's email as verified to auto-link
38+
* into their account. `sso/delete-provider` likewise lets an owner drop a login
39+
* path outside the application's flow.
40+
*
41+
* `trustEmailVerified: false` independently defuses that claim, so these two
42+
* guards are layered, not redundant: this one keeps provider configuration
43+
* owned by the register route (which alone proves domain ownership) and is what
44+
* stops the mapping rewrite from becoming live again if that option is ever
45+
* reconsidered.
46+
*
47+
* Deny-by-default rather than a blocklist so a future plugin version cannot
48+
* introduce another unshadowed provider mutation.
49+
*/
50+
function isBlockedSsoMutationPath(path: string): boolean {
51+
return path.startsWith('sso/') && !path.startsWith(SAML_PROTOCOL_POST_PREFIX)
52+
}
53+
2254
export const GET = withRouteHandler(async (request: NextRequest) => {
2355
const path = getAuthPath(request)
2456

@@ -40,5 +72,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4072
)
4173
}
4274

75+
if (isBlockedSsoMutationPath(path)) {
76+
return NextResponse.json(
77+
{ error: 'SSO provider mutations are handled by application API routes.' },
78+
{ status: 404 }
79+
)
80+
}
81+
4382
return betterAuthPOST(request)
4483
})

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)