fix(auth): scope SSO account linking to the verified domain and fence plugin provider mutations - #6738
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryHigh Risk Overview Adds a deny-by-default fence on the auth catch-all POST handler for paths under Regression tests cover blocked SSO mutations, allowed SAML POST paths, SSO sign-in, and locked SSO trust options; route tests drop unnecessary Reviewed by Cursor Bugbot for commit 350d26a. Configure here. |
Greptile SummaryThis PR hardens SSO account linking by requiring verified-domain trust and prevents direct plugin-provider mutations through the auth catch-all.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| apps/sim/app/api/auth/[...all]/route.ts | Adds a deny-by-default fence for plugin-served SSO POST routes while retaining the required SAML protocol paths. |
| apps/sim/lib/auth/auth.ts | Disables trust in IdP-supplied email-verification claims so SSO linking depends on verified-domain trust. |
| apps/sim/app/api/auth/[...all]/route.test.ts | Adds route-fence regression coverage and fully removes the explicit-any casts reported in the previous thread. |
| apps/sim/lib/auth/sso-trust.test.ts | Adds regression assertions that email-verification claims remain untrusted and domain verification remains enabled. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
Request[POST /api/auth/*] --> Route[Auth catch-all]
Route -->|sso/saml2/*| BetterAuth[Better Auth SAML protocol handler]
Route -->|other sso/*| Block[Return 404]
Route -->|sign-in/sso| BetterAuth
BetterAuth --> Trust{Verified provider domain matches email?}
Trust -->|Yes| Link[Allow existing-account linking]
Trust -->|No| Reject[Reject account linking]
Reviews (2): Last reviewed commit: "test(auth): drop unnecessary any casts f..." | Re-trigger Greptile
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.
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.
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.
f4409fb to
350d26a
Compare
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 350d26a. Configure here.
Summary
trustEmailVerified: falseon the SSO plugin. Better Auth's link gate is!isTrustedProvider && !userInfo.emailVerified, so a truthy IdPemail_verifiedclaim substituted for the domain binding entirely — any principal able to register a provider could point it at an IdP they control, assert an unrelated address as verified, and auto-link into that account. Linking now requiresisTrustedProvider, which isdomainVerifiedplusvalidateEmailDomain(email, provider.domain), so a provider can only claim identities inside the domain it proved.sso/update-providerwas gated only on provider ownership and merged the caller'ssamlConfig, letting an owner restore themapping.emailVerifiedthe register contract omits — a field the plugin's identity-boundary guard doesn't inspect, so it never tripped the linked-account conflict.sso/delete-providerand the DNS-challenge endpoints were likewise reachable. Deny-by-default undersso/, allowing only thesso/saml2/protocol paths the IdP posts to, mirroring the existing organization fence./api/auth/sso/register, which is the only path that proves domain ownership.The option only ever set
emailVerifiedon 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.domainVerificationwas already the sole linking trust source, which is whattrustProviderByName: falseassumes.Behavior change worth noting for self-hosted: a first-time SSO sign-in by a user who already has a local account and whose email domain does not match the provider's registered domain is now rejected as "account not linked". New users are unaffected — the gate only runs when a matching local user exists. Domain matching accepts subdomains; a deployment with multiple email domains needs one provider per domain.
Type of Change
Testing
Added regression tests for both fixes, each verified to fail when its fix is reverted: the catch-all guard (blocked mutations, allowed SAML protocol paths, sign-in untouched) and the SSO trust config. Full auth/org/billing suites pass (1768 tests, 154 files), all 27 CI audits pass,
tsc --noEmitclean. Router path matching confirmed case-sensitive, so the deny-by-default prefix has no bypass.Checklist