Skip to content

feat: add organization domain verification with OTP email flow and SSO routing integration#23559

Merged
rephus merged 1 commit into
mainfrom
05-27-feat_add_organization_domain_verification_with_otp_email_flow_and_sso_routing_integration
May 28, 2026
Merged

feat: add organization domain verification with OTP email flow and SSO routing integration#23559
rephus merged 1 commit into
mainfrom
05-27-feat_add_organization_domain_verification_with_otp_email_flow_and_sso_routing_integration

Conversation

@rephus
Copy link
Copy Markdown
Collaborator

@rephus rephus commented May 27, 2026

Closes: https://linear.app/lightdash/issue/PROD-7516/domain-verification
Closes: https://linear.app/lightdash/issue/PROD-7216/add-organization-level-domain-allowlist-settings

CleanShot 2026-05-28 at 09 19 19 CleanShot 2026-05-27 at 16 43 55 CleanShot 2026-05-27 at 16 44 30 CleanShot 2026-05-27 at 16 44 54

Description:

Introduces organization domain verification — a flow that lets organization admins prove they own a domain by receiving a one-time passcode at an address on that domain. Verified domains become the single source of truth for SSO routing, replacing the previous approach of routing based on the organization's allowed email domains list.

Backend:

  • New organization_domain_verifications table with a partial unique index on (domain) WHERE verified_at IS NOT NULL, enforcing a global first-verified-wins rule so only one organization can hold a verified domain at a time.
  • OrganizationDomainVerificationModel handles challenge upserts (bcrypt-hashed passcodes), attempt tracking, passcode comparison, and marking domains as verified.
  • OrganizationDomainVerificationService orchestrates the full lifecycle: requesting a challenge (sends a branded email via a new domainVerification email template), confirming a passcode, listing verified domains, and deleting a domain. Rejects public email providers and domains already claimed by another org.
  • OrganizationSsoModel now joins against organization_domain_verifications instead of organization_allowed_email_domains when resolving which SSO methods to offer for a given email domain. Methods with override_email_domains = false route all of the org's verified domains; methods with override_email_domains = true route only the configured subset.
  • OrganizationSsoService validates that any domains added to an override subset are already verified by the organization.
  • Shared OTP helpers (generateOneTimePasscode, isOtpExpired, isOtpMaxAttempts, otpExpirationDate) extracted into a reusable utility with unit tests.
  • isPublicEmailProviderDomain consolidated into @lightdash/common, merging the previous EMAIL_PROVIDER_LIST with the separate blocklist that lived in OrganizationSsoService.
  • New REST endpoints under GET/POST/DELETE /api/v1/org/domains with full OpenAPI/tsoa definitions.

Frontend:

  • New Verified Domains settings panel (/generalSettings/verifiedDomains) where admins can add a domain (entering a challenge email), enter the 6-digit code with a live countdown timer, and remove verified domains.
  • SSO method panels (Google, Okta, Azure AD, OneLogin, Generic OIDC) now use a shared SsoMethodDomainsField component. The override toggle switches from a free-text tags input to a MultiSelect restricted to the organization's verified domains, with a link to the verified domains page when none exist.

Copy link
Copy Markdown
Collaborator Author

rephus commented May 27, 2026

This stack of pull requests is managed by Graphite. Learn more about stacking.

@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 27, 2026

PROD-7516

PROD-7216

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 27, 2026

🧪 Test Selection

✅ Tests that will run

Test Description
Preview Environment Deploys a preview environment for testing
Frontend E2E Tests Runs Cypress app tests
Backend API Tests Runs Vitest API tests
CLI Tests Runs CLI integration and dbt version tests

⏭️ Tests skipped (no relevant file changes detected)

Test How to trigger manually
Timezone Tests Add test-timezone to PR description

Tip: Add test-all to your PR description to run all tests.

* from a maintained list (e.g. Kickbox `free-email-domains`) if needed — keep
* it a vendored static set, never a runtime fetch.
*/
export const PUBLIC_EMAIL_PROVIDER_DOMAINS = new Set([
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This was already rejecting public emails, this is not the full list, and it is not scalable, we should relay on a different approach, like an npm package with a more comprehensive list

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 27, 2026

Your preview environment pr-23559 has been deployed with errors.

@github-actions
Copy link
Copy Markdown

Preview Environment

🌐 URL: https://lightdash-preview-pr-23559.lightdash.okteto.dev

📋 Logs: View in GCP Console

🔧 SSH: ./scripts/okteto-ssh.sh 23559

@rephus rephus force-pushed the 05-27-feat_add_organization_domain_verification_with_otp_email_flow_and_sso_routing_integration branch from b1f224e to d06a81a Compare May 28, 2026 07:21
@rephus rephus force-pushed the 05-27-feat_add_organization_domain_verification_with_otp_email_flow_and_sso_routing_integration branch from d06a81a to 6114456 Compare May 28, 2026 07:31
* Domain verification gates the per-organization SSO settings, so it shares
* the same feature flag.
*/
private async assertFeatureEnabled(
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

currently, this is enabled using hte SsoOrganizationSettings which is the only valid use of verified domains, and a dependency for adding sso providers

@rephus rephus marked this pull request as ready for review May 28, 2026 07:39
@rephus rephus merged commit 9e5abc5 into main May 28, 2026
9 of 10 checks passed
@rephus rephus deleted the 05-27-feat_add_organization_domain_verification_with_otp_email_flow_and_sso_routing_integration branch May 28, 2026 07:40
@mintlify
Copy link
Copy Markdown

mintlify Bot commented May 28, 2026

Docs PR opened: lightdash/mintlify-docs#715

Added a new Verified domains reference page explaining how admins verify organization domains for SSO routing.

Comment on lines +66 to +76
async requestVerification(
@Request() req: express.Request,
@Body() body: RequestDomainVerification,
): Promise<ApiDomainVerificationStatusResponse> {
assertRegisteredAccount(req.account);
const results = await this.services
.getOrganizationDomainVerificationService()
.requestVerification(req.account, body);
this.setStatus(200);
return { status: 'ok', results };
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The backend rules require 'proper request validation using zod' in controllers. The requestVerification endpoint accepts body: RequestDomainVerification directly from tsoa without any zod validation of the incoming fields (e.g. domain format, email format). Add a zod schema to validate the body before passing it to the service.

Suggested change
async requestVerification(
@Request() req: express.Request,
@Body() body: RequestDomainVerification,
): Promise<ApiDomainVerificationStatusResponse> {
assertRegisteredAccount(req.account);
const results = await this.services
.getOrganizationDomainVerificationService()
.requestVerification(req.account, body);
this.setStatus(200);
return { status: 'ok', results };
}
async requestVerification(
@Request() req: express.Request,
@Body() body: RequestDomainVerification,
): Promise<ApiDomainVerificationStatusResponse> {
assertRegisteredAccount(req.account);
const validatedBody = requestDomainVerificationSchema.parse(body);
const results = await this.services
.getOrganizationDomainVerificationService()
.requestVerification(req.account, validatedBody);
this.setStatus(200);
return { status: 'ok', results };
}

Spotted by Graphite (based on custom rule: packages/backend rules)

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Comment on lines +260 to +302
try {
const challenge =
await this.organizationDomainVerificationModel.verifyChallengePasscode(
{
organizationUuid,
domain: normalizedDomain,
passcode,
},
);
if (
!isOtpExpired(challenge.createdAt) &&
!isOtpMaxAttempts(challenge.numberOfAttempts)
) {
try {
await this.organizationDomainVerificationModel.markChallengeVerified(
{
organizationUuid,
domain: normalizedDomain,
verifiedByUserUuid: account.user.userUuid,
},
);
} catch (error) {
if (isUniqueViolation(error)) {
throw new ParameterError(
`${normalizedDomain} has already been verified by another organization.`,
);
}
throw error;
}
}
} catch (error) {
// Wrong/expired passcode — count the attempt, mirroring email OTP.
if (error instanceof NotFoundError) {
await this.organizationDomainVerificationModel.incrementChallengeAttempts(
{
organizationUuid,
domain: normalizedDomain,
},
);
} else {
throw error;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Security concern: when verifyChallengePasscode throws a NotFoundError (wrong passcode), the code increments the attempt counter but then falls through to findByOrgAndDomain and returns the current status — effectively giving the caller no indication that the passcode was wrong beyond inspecting isVerified. This is a subtle information-leakage / brute-force risk: a caller can distinguish a wrong code (status returned, isVerified=false) from a missing challenge (NotFoundError re-thrown). The response should be consistent regardless of whether the passcode was wrong or no challenge exists, to avoid leaking state. Consider returning a uniform error or status for both cases.

Suggested change
try {
const challenge =
await this.organizationDomainVerificationModel.verifyChallengePasscode(
{
organizationUuid,
domain: normalizedDomain,
passcode,
},
);
if (
!isOtpExpired(challenge.createdAt) &&
!isOtpMaxAttempts(challenge.numberOfAttempts)
) {
try {
await this.organizationDomainVerificationModel.markChallengeVerified(
{
organizationUuid,
domain: normalizedDomain,
verifiedByUserUuid: account.user.userUuid,
},
);
} catch (error) {
if (isUniqueViolation(error)) {
throw new ParameterError(
`${normalizedDomain} has already been verified by another organization.`,
);
}
throw error;
}
}
} catch (error) {
// Wrong/expired passcode — count the attempt, mirroring email OTP.
if (error instanceof NotFoundError) {
await this.organizationDomainVerificationModel.incrementChallengeAttempts(
{
organizationUuid,
domain: normalizedDomain,
},
);
} else {
throw error;
}
}
try {
const challenge =
await this.organizationDomainVerificationModel.verifyChallengePasscode(
{
organizationUuid,
domain: normalizedDomain,
passcode,
},
);
if (isOtpExpired(challenge.createdAt)) {
throw new ParameterError(
`Verification code has expired. Please request a new one.`,
);
}
if (isOtpMaxAttempts(challenge.numberOfAttempts)) {
throw new ParameterError(
`Maximum verification attempts exceeded. Please request a new code.`,
);
}
try {
await this.organizationDomainVerificationModel.markChallengeVerified(
{
organizationUuid,
domain: normalizedDomain,
verifiedByUserUuid: account.user.userUuid,
},
);
} catch (error) {
if (isUniqueViolation(error)) {
throw new ParameterError(
`${normalizedDomain} has already been verified by another organization.`,
);
}
throw error;
}
} catch (error) {
if (error instanceof ParameterError) {
throw error;
}
// Wrong passcode — count the attempt and return a uniform error.
if (error instanceof NotFoundError) {
await this.organizationDomainVerificationModel.incrementChallengeAttempts(
{
organizationUuid,
domain: normalizedDomain,
},
);
throw new ParameterError(
`Invalid verification code. Please check the code and try again.`,
);
}
throw error;
}

Spotted by Graphite (based on custom rule: Security rules)

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@@ -0,0 +1,228 @@
import { type DomainVerificationStatus } from '@lightdash/common';
import { Button, PinInput, Stack, Text, TextInput } from '@mantine-8/core';
import { useForm } from '@mantine/form';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The frontend rules require using @mantine/form with Zod for validation ('Use Mantine useForm with Zod for validation'). The form here uses useForm from @mantine/form but the validate object uses a plain function instead of a Zod schema. Replace the inline validate functions with a Zod schema passed to zodResolver.

Spotted by Graphite (based on custom rule: packages/frontend rules)

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

lightdash-bot pushed a commit that referenced this pull request May 28, 2026
# [0.3029.0](0.3028.1...0.3029.0) (2026-05-28)

### Features

* add organization domain verification with OTP email flow and SSO routing integration ([#23559](#23559)) ([9e5abc5](9e5abc5))
@lightdash-bot
Copy link
Copy Markdown
Collaborator

🎉 This PR is included in version 0.3029.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants