Skip to content

feat(sso): DNS domain verification gating org SSO registration#5909

Open
waleedlatif1 wants to merge 18 commits into
stagingfrom
feat/sso-domain-verification
Open

feat(sso): DNS domain verification gating org SSO registration#5909
waleedlatif1 wants to merge 18 commits into
stagingfrom
feat/sso-domain-verification

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • Adds org-scoped domain ownership verification (DNS TXT challenge) as the security precondition for configuring SSO — closes the first-come domain-claim vuln where any org could point another company's domain at its own IdP
  • New `sso_domain` table + migration 0266; existing org SSO domains are grandfathered as verified, so live tenants keep working with no action
  • New Settings → Security → Verified domains UI (enterprise-gated): add a domain, publish the shown TXT record, verify, remove
  • Register route now requires a verified domain for org-scoped registration; personal SSO and grandfathered domains are unaffected, and login matching still runs through `ssoProvider.domain`
  • Self-host `register-sso-provider` script writes the verified `sso_domain` row directly, so script-driven registration stays backwards compatible

Existing-customer impact

  • None. Cloud prod's only grandfather target (`rivianvw.tech`) is canonical and grandfathers correctly (validated against PlanetScale). The gate only fires for org-scoped registration of an unverified domain; existing/personal SSO is untouched. Removing a verified domain does not sign anyone out.

Design note

  • Better Auth 1.6.23 ships a native `domainVerification` feature, but it is provider-scoped. This is a deliberate org-scoped implementation (org-owned domains, global uniqueness, grandfathering, and future force-SSO / auto-join keyed on the org).

Type of Change

  • New feature
  • Security fix

Testing

  • 110 tests across SSO/organizations/settings-nav/domain libs pass (incl. a new gate test)
  • Full `tsc` 0 errors; `check:api-validation`, `check:migrations` (backward-compatible), `check:utils` all pass
  • Migration validated against PlanetScale prod (grandfathers exactly one row, no index collision)

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 Jul 24, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Jul 24, 2026 3:15am

Request Review

@cursor

cursor Bot commented Jul 24, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Touches authentication/SSO registration and domain ownership enforcement with TOCTOU handling; migration grandfathers live SSO domains but mis-verification or rollback bugs could block or incorrectly allow org SSO setup.

Overview
Introduces verified domains as the prerequisite for org-scoped SSO: Enterprise admins claim a domain, publish a _sim-challenge TXT record, and verify ownership before SSO can be configured for that domain.

Data & APIs: New sso_domain table (migration 0268) with per-org claims, global uniqueness on verified domains, and a backfill that marks existing org SSO provider domains as verified. Organization routes support list/add, DNS verify, and delete, with Enterprise and admin gates, audit events, idempotent claims, and TXT tokens hidden from non-admin members.

SSO register route: Org registrations now require a matching verified sso_domain row (SSO_DOMAIN_NOT_VERIFIED), with re-checks and rollback if verification is revoked mid-flight; personal SSO is unchanged. Also routes existing providers through updateSSOProvider, nests attribute mapping under oidcConfig/samlConfig, and tightens personal secret lookup with organizationId IS NULL.

Product surface: Verified domains settings (nav + DomainSettings UI + React Query hooks) and enterprise docs page.

Reviewed by Cursor Bugbot for commit 085a962. Configure here.

Comment thread apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts Outdated
@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds DNS-based organization domain verification as a prerequisite for configuring organization SSO.

  • Introduces the sso_domain schema, uniqueness constraints, migration, and grandfathering of existing SSO domains.
  • Adds enterprise-gated APIs and settings UI for adding, verifying, listing, and removing domains.
  • Gates organization-scoped SSO registration on verified ownership and updates the self-host registration script to persist ownership records.
  • Centralizes SSO domain normalization in @sim/utils and adds supporting tests and documentation.

Confidence Score: 5/5

The pull request appears safe to merge.

No blocking failures remain within the scope of the previous review threads.

Important Files Changed

Filename Overview
apps/sim/app/api/auth/sso/register/route.ts Adds verified-domain gating, provider update handling, custom mapping placement, and compensation for revoked verification during provider creation.
apps/sim/app/api/organizations/[id]/domains/route.ts Adds enterprise-gated domain listing and idempotent pending-domain creation with normalized domain keys.
apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts Verifies DNS challenges using a challenge-bound conditional update and handles concurrent verification changes.
packages/db/schema.ts Defines organization domain ownership records and uniqueness constraints for organization claims and globally verified domains.
packages/db/migrations/0268_sso_domain_verification.sql Creates the domain-verification table and grandfathers existing organization SSO domains.
packages/db/scripts/register-sso-provider.ts Atomically replaces self-hosted provider configuration and records verified ownership using shared domain normalization.
packages/utils/src/sso-domain.ts Provides shared canonicalization and validation for SSO domains across runtime and script paths.

Sequence Diagram

sequenceDiagram
  participant Admin
  participant DomainAPI as Verified Domains API
  participant DNS
  participant DB
  participant SSO as SSO Registration API

  Admin->>DomainAPI: Add organization domain
  DomainAPI->>DB: Insert pending domain and challenge
  DomainAPI-->>Admin: Return TXT record
  Admin->>DNS: Publish TXT challenge
  Admin->>DomainAPI: Verify domain
  DomainAPI->>DNS: Resolve TXT record
  DNS-->>DomainAPI: Matching challenge
  DomainAPI->>DB: Mark domain verified
  Admin->>SSO: Configure organization SSO
  SSO->>DB: Confirm verified domain ownership
  DB-->>SSO: Verified
  SSO->>DB: Create or update SSO provider
  SSO-->>Admin: Configuration saved
Loading

Reviews (19): Last reviewed commit: "refactor(sso): share normalizeSSODomain ..." | Re-trigger Greptile

Comment thread apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts Outdated
Comment thread apps/sim/app/api/organizations/[id]/domains/route.ts Outdated
Comment thread packages/db/scripts/register-sso-provider.ts Outdated
@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
Comment thread apps/sim/app/api/organizations/[id]/domains/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 dbae0ea. 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

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

Comment thread apps/sim/app/api/auth/sso/register/route.ts
@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/route.ts
Comment thread apps/sim/app/api/organizations/[id]/domains/route.ts
Comment thread packages/db/scripts/register-sso-provider.ts
@waleedlatif1
waleedlatif1 force-pushed the feat/sso-domain-verification branch from b41fefb to 6b9261c Compare July 24, 2026 01:02
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

Update after a comprehensive independent audit (5 tracks: Better Auth API alignment, regression/UX, adversarial correctness, cleanup, TOCTOU feasibility). Pushed 6b9261c:

  • Register TOCTOU now actually closed (superseding my earlier by-design note). Confirmed against Better Auth 1.6.23 source that registerSSOProvider is create-only (throws UNPROCESSABLE_ENTITY if the providerId exists), so a compensating post-write delete is provably safe — it can only ever remove the row we just created, never a pre-existing provider. If the verified domain is revoked during the write window, the new provider is rolled back and we 403.
  • Verify idempotency under concurrency: a same-org row already flipped to verified by a racing request now returns 200 (was a confusing 409).
  • Backfill/script normalization hardened to match normalizeSSODomain's dominant transforms (lower + trim + strip leading wildcard); validated against prod that the grandfather result is unchanged.
  • Minor cleanup (dead default export, card radius).

All gates green (tsc 0, api-validation, migrations backward-compatible, db lint, 74 affected tests). @greptile @cursor please re-review.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread packages/db/scripts/register-sso-provider.ts Outdated
…igration edge

Cosmetic cleanup from a final 4-track adversarial review (no bugs found in the
new logic):
- Self-host script: narrow the pre-transaction existence read to select({ id })
  instead of SELECT * (it only feeds a log line now).
- Unify invalid-domain copy ("for example acme.com") and the verified-elsewhere
  409 wording ("is already verified by another organization") across routes.
- p-3 shorthand on the domain row card.
- Document the migration's rare two-orgs-share-a-domain grandfather behavior
  (login unaffected; validated no such duplicates in prod).
Two pre-existing SSO bugs the final review surfaced (prod has one SSO org, RVW,
script-registered with the default mapping, so neither change affects it):

- Attribute mapping was passed at the top level of the register payload, which
  Better Auth ignores — it reads oidcConfig.mapping / samlConfig.mapping. Nest it
  so custom mappings actually apply. (Default mapping is unchanged, so existing
  logins are unaffected.)
- Editing an SSO provider was broken: registerSSOProvider is create-only and
  threw on the existing providerId → generic 500. Route now detects a provider
  the caller already owns and updates it via Better Auth's updateSSOProvider, and
  surfaces Better Auth's own error status/message instead of a blanket 500.

Also drops the now-unnecessary providerExistedBefore guard (the rollback deletes
by the created row's primary-key id and register is create-only) and the earlier
final-review polish (script read, unified copy, migration edge note).

Smoke-test SSO login + edit on staging before merge (auth-path change).
…pass)

The personal branch of both provider-ownership lookups keyed on
(providerId, userId) without requiring organizationId IS NULL. Because org
providers store userId = their creator and providerId is globally unique, an org
admin could send a personal-mode request (no orgId) — which skips the membership
check and the domain-verification gate — yet still match, and then via the new
update path move, their org's provider to an unverified domain. Add
isNull(organizationId) to the personal branch of both clauses so it can only
match a genuinely personal provider, matching the route's own isOwnedByCaller.

Found by an adversarial review of the update path added in 394bda9.
Inside the registration transaction the script updated WHERE providerId — the
logical key. If the observed provider was deregistered and a replacement created
with the same providerId before the transaction ran, that update would clobber
the replacement's config and ownership. Update the specific observed row by its
primary-key id instead; if it's gone we insert, which fails cleanly on the
providerId unique constraint rather than overwriting the replacement.
@waleedlatif1
waleedlatif1 force-pushed the feat/sso-domain-verification branch from 56ef036 to 464e7e0 Compare July 24, 2026 02:37
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread packages/db/migrations/0268_sso_domain_verification.sql
Comment thread packages/db/scripts/register-sso-provider.ts Outdated
…onstraint)

sso_provider.provider_id is a plain (non-unique) index and prod holds legitimate
duplicates, so the previous "update by id, else insert" could create a duplicate
provider when the observed row was deregistered and replaced before the
transaction — the fallback insert would succeed. Delete every row for the
providerId then insert exactly one, inside the transaction, so the providerId
ends up as exactly this config atomically. Linked accounts key on the providerId
string (not the row id), so existing logins are unaffected.
@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
Comment thread packages/db/scripts/register-sso-provider.ts
…rification

# Conflicts:
#	packages/db/migrations/meta/0266_snapshot.json
#	packages/db/migrations/meta/_journal.json
#	scripts/check-api-validation-contracts.ts
Staging landed migrations 0266/0267, colliding with our 0266. Removed our
migration, merged staging, and regenerated cleanly with drizzle-kit as
0268_sso_domain_verification (identical sso_domain table + indexes), then
re-appended the grandfather backfill. api-validation baseline reconciled to 973
(staging 970 + our 3 domain routes). Also make the register-route test's
registerSSOProvider mock return an id so the guarded compensating delete runs.
@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 dce6d63. Configure here.

Comment thread packages/db/scripts/register-sso-provider.ts Outdated
…hes gate

The self-host script canonicalized SSO domains with a minimal inline transform
(lower+trim+wildcard) that diverged from the app's full normalizeSSODomain
(protocol, port, path, trailing dot, email local part) — equivalent spellings
could store a different ownership key than the runtime gate looks up. Move
normalizeSSODomain into @sim/utils/sso-domain (a pure function) so the register
route, the domain-claim route, and the script all use the identical canonicalizer.
The script now skips the verified-domain record when SSO_DOMAIN isn't a valid
registrable domain instead of storing a malformed key.
@waleedlatif1
waleedlatif1 requested a review from a team as a code owner July 24, 2026 03:10
@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 085a962. Configure here.

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