Skip to content

fix(auth): scope SSO account linking to the verified domain and fence plugin provider mutations - #6738

Merged
waleedlatif1 merged 4 commits into
stagingfrom
fix/sso-plugin-endpoint-shadowing
Aug 15, 2026
Merged

fix(auth): scope SSO account linking to the verified domain and fence plugin provider mutations#6738
waleedlatif1 merged 4 commits into
stagingfrom
fix/sso-plugin-endpoint-shadowing

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • Set trustEmailVerified: false on the SSO plugin. Better Auth's link gate is !isTrustedProvider && !userInfo.emailVerified, so a truthy IdP email_verified claim 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 requires isTrustedProvider, which is domainVerified plus validateEmailDomain(email, provider.domain), so a provider can only claim identities inside the domain it proved.
  • Fence off the SSO endpoints the plugin serves directly through the auth catch-all. sso/update-provider was gated only on provider ownership and merged the caller's samlConfig, letting an owner restore the mapping.emailVerified the register contract omits — a field the plugin's identity-boundary guard doesn't inspect, so it never tripped the linked-account conflict. sso/delete-provider and the DNS-challenge endpoints were likewise reachable. Deny-by-default under sso/, allowing only the sso/saml2/ protocol paths the IdP posts to, mirroring the existing organization fence.
  • The two guards are layered, not redundant: provider configuration stays owned by /api/auth/sso/register, which is the only path that proves domain ownership.

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. domainVerification was already the sole linking trust source, which is what trustProviderByName: false assumes.

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

  • Bug fix

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 --noEmit clean. Router path matching confirmed case-sensitive, so the deny-by-default prefix has no bypass.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel

vercel Bot commented Aug 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Aug 15, 2026 9:06pm

Request Review

@cursor

cursor Bot commented Aug 15, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes authentication trust boundaries and SSO account linking; incorrect path allowlisting could break SAML login or leave mutation endpoints exposed.

Overview
Closes an SSO account-linking bypass by setting trustEmailVerified: false on the Better Auth SSO plugin. With it enabled, a truthy IdP email_verified claim could satisfy the link gate without domain binding, so a registered provider could assert out-of-domain emails and auto-link into existing accounts. Linking now depends on domainVerification and email matching the provider’s proved domain.

Adds a deny-by-default fence on the auth catch-all POST handler for paths under sso/, returning 404 for plugin provider mutations (update-provider, delete-provider, domain verification, etc.) so configuration stays on /api/auth/sso/register. Only sso/saml2/ protocol endpoints and routes like sign-in/sso still delegate to Better Auth—mirroring the existing organization mutation guard.

Regression tests cover blocked SSO mutations, allowed SAML POST paths, SSO sign-in, and locked SSO trust options; route tests drop unnecessary as any casts.

Reviewed by Cursor Bugbot for commit 350d26a. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR hardens SSO account linking by requiring verified-domain trust and prevents direct plugin-provider mutations through the auth catch-all.

  • Sets trustEmailVerified to false, leaving domain verification as the SSO account-linking trust source.
  • Denies plugin-served SSO mutation endpoints while preserving SAML protocol and SSO sign-in paths.
  • Adds regression coverage for the route fence and SSO trust configuration.
  • Removes the unnecessary explicit-any casts identified in the previous review thread.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

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]
Loading

Reviews (2): Last reviewed commit: "test(auth): drop unnecessary any casts f..." | Re-trigger Greptile

Comment thread apps/sim/app/api/auth/[...all]/route.test.ts
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.
@waleedlatif1
waleedlatif1 force-pushed the fix/sso-plugin-endpoint-shadowing branch from f4409fb to 350d26a Compare August 15, 2026 21:06
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@waleedlatif1
waleedlatif1 merged commit 77f520c into staging Aug 15, 2026
30 checks passed
@waleedlatif1
waleedlatif1 deleted the fix/sso-plugin-endpoint-shadowing branch August 15, 2026 23:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant