Skip to content

fix(sso): link Entra sign-ins to existing accounts and enforce unique provider IDs - #6311

Merged
waleedlatif1 merged 24 commits into
stagingfrom
worktree-sso-domain-verify-audit
Aug 6, 2026
Merged

fix(sso): link Entra sign-ins to existing accounts and enforce unique provider IDs#6311
waleedlatif1 merged 24 commits into
stagingfrom
worktree-sso-domain-verify-audit

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • Set domainVerified on SSO providers so an SSO sign-in links to an existing same-email account. Better Auth 1.6.23 passes trustProviderByName: false, which disables the trustedProviders allowlist for SSO entirely — trust now comes only from that flag, which Sim never set. Entra never sends email_verified, so every Microsoft tenant hit "account not linked".
  • Sim already proves domain ownership via sso_domain before a provider can be registered, so the register route mirrors that decision onto the flag. registerSSOProvider always writes false and updateSSOProvider resets it on a domain change, so it is re-applied after every write.
  • Column defaults to true: enabling the option turns sign-in into a hard gate, so existing providers must already satisfy it or they'd be locked out mid-deploy.
  • Enforce the providerId uniqueness Better Auth already assumes. It rejects any id present in any tenant and resolves providers by that column alone, so a second customer picking azure-ad could not register at all and got an opaque 422. Now returns a 409 naming a free id, plus a unique index.
  • Docs: add the domain-verification prerequisite (it was missing entirely), the two Entra steps that cause most first sign-in failures (email optional claim, app assignment), and correct the settings path — it said Enterprise, the section is under Security.

Type of Change

  • Bug fix

Testing

280 auth/org tests pass, including 6 new cases covering the providerId collision, the suggested replacement id, and the domainVerified write on both create and update paths. Verified each new test fails when the fix is reverted. Typecheck, biome, check:migrations, and check:api-validation all pass.

Migration is replay-safe: idempotent before the COMMIT, IF NOT EXISTS / IF EXISTS on the concurrent index operations, and it builds the unique index before dropping the old one so provider_id is never unindexed. It fails loudly inside the transaction if duplicate provider_id values exist rather than stranding an INVALID index.

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 6, 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 6, 2026 7:46am

Request Review

@cursor

cursor Bot commented Aug 6, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes authentication linking, domain-trust revocation, and SSO registration concurrency in security-critical paths; migration enforces global provider_id uniqueness and fails if duplicates exist.

Overview
Fixes Entra and other IdPs that never send email_verified by driving SSO account linking off domainVerified on sso_provider (with Better Auth domainVerification enabled), set after DNS-verified domain registration instead of relying on trustedProviders / SSO_TRUSTED_PROVIDER_IDS for SSO.

The register route now rejects globally taken provider IDs with 409 and a suggested id, re-checks conflicts before write, grants trust under a FOR SHARE lock on the verified domain row, rolls back creates or reverts updates if proof disappears mid-request, and treats hosted vs self-hosted personal providers differently. SAML saves stop auto-generating IdP metadata and always pass empty identifierFormat / idpMetadata so cert rotation and NameID defaults actually clear on update.

Verified domain delete and verify APIs revoke or restore domainVerified in the same transaction (wildcard-tolerant domain matching). Migration 0284 adds the column (default true for existing rows) and a unique index on provider_id.

Enterprise SSO UI adds attribute mapping, OIDC advanced endpoints, SAML NameID format, SP entity ID, and clearer provider-id guidance; docs cover prerequisites, Entra steps, and the Security settings path.

Reviewed by Cursor Bugbot for commit 7b735e4. Configure here.

Comment thread apps/sim/app/api/auth/sso/register/route.ts Outdated
@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR links SSO identities through verified-domain trust, enforces globally unique provider IDs, and coordinates trust revocation/restoration with domain lifecycle changes. It also expands SSO configuration and documentation.

  • Adds transactional domain-trust grants, revocation, restoration, and failed-write compensation.
  • Adds the domain_verified schema field and globally unique provider_id index.
  • Adds advanced OIDC/SAML settings and provider-ID collision guidance.
  • Updates Entra and verified-domain setup documentation.

Confidence Score: 4/5

The PR is not yet safe to merge because a failed domain-trust grant can overwrite a newer successful SSO provider update.

The rollback writes an old provider snapshot by row ID without a lock, version check, or comparison against this request's update, so an overlapping successful save can be silently replaced and have its trust cleared.

Files Needing Attention: apps/sim/app/api/auth/sso/register/route.ts

Important Files Changed

Filename Overview
apps/sim/app/api/auth/sso/register/route.ts Adds provider collision handling and domain-trust grant/rollback logic, but the unguarded compensating rollback can overwrite a concurrent successful provider edit.
apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts Deletes domain proof and revokes matching provider trust in one transaction with wildcard-compatible normalization.
apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts Restores matching provider trust transactionally when pending domain verification succeeds.
packages/db/migrations/0284_sso_provider_domain_verified.sql Adds the domain-verification flag and replaces the provider-ID index with a replay-aware global unique index.
apps/sim/ee/sso/components/sso-settings.tsx Adds advanced OIDC endpoints, claim mappings, SAML NameID selection, and globally unique provider-ID guidance.

Sequence Diagram

sequenceDiagram
  participant A as Admin request A
  participant B as Admin request B
  participant P as SSO provider
  participant D as Domain proof
  A->>P: Read rollback snapshot
  A->>P: Save configuration A
  B->>P: Read current configuration
  B->>P: Save configuration B
  B->>D: Lock verified proof
  B->>P: Grant domain trust
  D-->>A: Proof unavailable
  A->>P: Restore stale snapshot and clear trust
  Note over P: Successful configuration B is lost
Loading

Reviews (24): Last reviewed commit: "fix(sso): revert a rejected SSO update i..." | Re-trigger Greptile

Comment thread apps/sim/app/api/auth/sso/register/route.ts Outdated
@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 ca48eb5. Configure here.

Comment thread apps/sim/app/api/auth/sso/register/route.ts Outdated
… provider IDs

Better Auth 1.6.23 calls the account-linking handler with trustProviderByName:
false, which disables the trustedProviders allowlist for SSO entirely. Trust now
comes only from the provider's domainVerified flag, which Sim never set — so any
user who already had a Sim account was stranded on "account not linked". Entra
never sends email_verified, so this hit every Microsoft tenant.

Sim already proves domain ownership via sso_domain before a provider can be
registered, so the register route mirrors that decision onto domainVerified.
The column defaults to true so existing providers keep signing in across the
deploy, since enabling the option turns sign-in into a hard gate.

Also enforces the providerId uniqueness Better Auth already assumes: it rejects
any id that exists in any tenant and resolves providers by that column alone, so
a second customer picking "azure-ad" could not register at all and got an opaque
422. Sim now returns a 409 naming a free id, and a unique index makes the
duplicate-row state unreachable.
The create path re-checks domain ownership after Better Auth persists the
provider and rolls the row back if the verified sso_domain row disappeared in
that window. The update path had no equivalent, so deleting the verified domain
while updateSSOProvider was in flight still set domainVerified, restoring
same-email account-linking trust for a domain the org no longer proves it owns.

The update path has no newly-created row to roll back, so it clears the flag
instead: that denies linking and blocks sign-in on the provider until the domain
is verified again.
Greptile flagged that the ownership check and the domainVerified write were
separate statements, so a domain deleted between them still ended with trust
granted. Two changes close it from both sides.

The grant now folds the ownership test into the UPDATE's WHERE clause, so
Postgres evaluates both in one statement and the write matches nothing once the
proof is gone.

Removing a verified domain now clears domainVerified for providers on that
domain, in the same transaction as the delete. This was a standing gap, not just
a race: deleting a domain previously left linking trust set indefinitely.

Together the provider cannot end up trusted without current ownership in either
commit order — if the grant lands first the delete clears it, and if the delete
lands first the grant no-ops.
@waleedlatif1
waleedlatif1 force-pushed the worktree-sso-domain-verify-audit branch from ca48eb5 to a0bd392 Compare August 6, 2026 04:58
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/auth/sso/register/route.ts Outdated
Comment thread apps/sim/app/api/auth/sso/register/route.ts Outdated
…ccess

The conditional grant could match zero rows if the verified domain was deleted
between the pre-write check and the write. The route ignored that and returned
200, leaving a provider that cannot sign anyone in while telling the admin it
saved.

The grant now reports whether it matched, and that result is the single decision
point on both paths: the create path rolls the provider back, the update path
clears the flag, and both return SSO_DOMAIN_NOT_VERIFIED. This also drops the
separate post-write ownership read, since the UPDATE re-tests ownership itself.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@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 4dea2d4. Configure here.

@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 4dea2d4. Configure here.

Comment thread apps/sim/app/api/auth/sso/register/route.ts
Identity providers disagree on which claim carries each value — Entra can send
the address as `upn` rather than `email` — and the mapping was hardcoded, so a
mismatch had no fix in the UI at all. Adds an Attribute mapping section for both
protocols, defaulting to each protocol's standard claim names shown as
placeholders, so the common case still needs no input.

Editing an existing provider now loads its stored mapping and only treats a
value as an override when it differs from the default, so a saved custom mapping
is never silently rewritten.
Comment thread apps/sim/app/api/auth/sso/register/route.ts Outdated

@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 ee5fd2d. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts
Comment thread apps/sim/app/api/auth/sso/register/route.ts
Comment thread apps/sim/app/api/auth/sso/register/route.ts
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile — acknowledging the summary, and explaining why I am not changing this, since it is the sole reason for the 3/5 and it keeps recurring each round.

Your description of the code is accurate. The org-less branch does set domainVerified to true when the deployment is not hosted:

if (!orgId) {
  await setProviderDomainVerified(!isHosted)
  return true
}

Two things narrow it to the point where it is not a merge blocker for this PR.

On hosted it is inert, not merely unlikely. isHosted is true there, so the branch writes domainVerified: false. Enabling domainVerification turns that flag into a hard sign-in gate in the library, not only a linking gate — @better-auth/sso/dist/index.mjs:1960, :2827, and :2978 each throw UNAUTHORIZED: Provider domain has not been verified before any provider resolution completes. An org-less provider on hosted therefore cannot complete a sign-in at all, so it cannot reach handleOAuthUserInfo and cannot link to anything. There is no ownership-bypass path on hosted.

Org-less registration is not reachable from the product. The only caller is apps/sim/ee/sso/components/sso-settings.tsx, whose prop is organizationId: string — required, non-nullable — and which passes it on both the OIDC and SAML branches. orgId is optional in the contract solely so a self-hosted deployment can call the endpoint directly. Production confirms the shape: every sso_provider row has an organization_id.

So the residual exposure is: a self-hosted instance, with more than one untrusted user, reached by a direct API call that bypasses the UI. That is real, and you are right that an org-less provider can never obtain DNS proof, because sso_domain is org-scoped. The reason it stays as-is here is that the alternative is worse: writing false would trip the same global sign-in gate above and break org-less SSO sign-in outright for self-hosters, which is a functional regression rather than a hardening. The org-less path is self-host-only and is being removed, which resolves this at the root instead of trading one break for another.

Scoring this 3/5 on a hosted-inert, UI-unreachable, self-host-only path understates a change whose actual effect is to restore account linking that has been broken in production since the better-auth 1.6.23 bump on 2026-07-22, and to make domain trust earned via DNS proof where it previously was not gated at all. Happy to be shown a concrete hosted path to the bypass if you have one — that would change my assessment.

@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Your argument holds. I had the hosted case wrong — because isHosted is true there, the branch writes false, which trips the hard sign-in gate in the library before provider resolution even begins. "Inert" is the right word; there is no execution path that reaches handleOAuthUserInfo from that branch on hosted. Combined with the UI contract (organizationId: string, non-nullable), the residual surface really is confined to a direct API call on a self-hosted multi-user deployment — and closing that by writing false would break org-less SSO sign-in there outright, which is a worse outcome than the theoretical risk on a deployment type that controls its own API surface.

The 3/5 was wrong. The PR restores production account linking that has been broken since the 1.6.23 bump, adds the providerId uniqueness enforcement, and the org-less concern is being resolved at the root by removing the path entirely. That's a net improvement on every axis.

@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 9fff2d3. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/auth/sso/register/route.ts Outdated

@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 27ade42. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/auth/sso/register/route.ts
@waleedlatif1
waleedlatif1 merged commit ab25755 into staging Aug 6, 2026
30 checks passed
@waleedlatif1
waleedlatif1 deleted the worktree-sso-domain-verify-audit branch August 6, 2026 07:52

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

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 7b735e4. Configure here.

Comment thread apps/sim/app/api/auth/sso/register/route.ts
Comment thread apps/sim/app/api/auth/sso/register/route.ts
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