diff --git a/packages/server/api/src/app/database/migration/postgres/1787100000000-AddSsoDomainVerification.ts b/packages/server/api/src/app/database/migration/postgres/1787100000000-AddSsoDomainVerification.ts new file mode 100644 index 00000000000..44925b83cc7 --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1787100000000-AddSsoDomainVerification.ts @@ -0,0 +1,42 @@ +import { QueryRunner } from 'typeorm' +import { system } from '../../../helper/system/system' +import { AppSystemProp } from '../../../helper/system/system-props' +import { DatabaseType } from '../../database-type' +import { Migration } from '../../migration' + +const isPGlite = system.get(AppSystemProp.DB_TYPE) === DatabaseType.PGLITE + +export class AddSsoDomainVerification1787100000000 implements Migration { + name = 'AddSsoDomainVerification1787100000000' + breaking = false + release = '0.83.0' + transaction = false + + public async up(queryRunner: QueryRunner): Promise { + const concurrently = isPGlite ? '' : 'CONCURRENTLY' + + await queryRunner.query(` + ALTER TABLE "platform" + ADD COLUMN IF NOT EXISTS "ssoDomain" character varying + `) + + await queryRunner.query(` + ALTER TABLE "platform" + ADD COLUMN IF NOT EXISTS "ssoDomainVerification" jsonb + `) + + await queryRunner.query(` + CREATE UNIQUE INDEX ${concurrently} IF NOT EXISTS "idx_platform_sso_domain" + ON "platform" ("ssoDomain") + WHERE "ssoDomain" IS NOT NULL + `) + } + + public async down(queryRunner: QueryRunner): Promise { + const concurrently = isPGlite ? '' : 'CONCURRENTLY' + + await queryRunner.query(`DROP INDEX ${concurrently} IF EXISTS "idx_platform_sso_domain"`) + await queryRunner.query('ALTER TABLE "platform" DROP COLUMN IF EXISTS "ssoDomainVerification"') + await queryRunner.query('ALTER TABLE "platform" DROP COLUMN IF EXISTS "ssoDomain"') + } +} diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index 95b89b1a160..12fb574186c 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -367,6 +367,7 @@ import { DropChatTokenColumns1782000000000 } from './migration/postgres/17820000 import { AddUserSandboxTable1784000000000 } from './migration/postgres/1784000000000-AddUserSandboxTable' import { ReplacesSandboxWithVercelAiSdk1785000000000 } from './migration/postgres/1785000000000-ReplacesSandboxWithVercelAiSdk' import { AddChatCompactionColumns1786000000000 } from './migration/postgres/1786000000000-AddChatCompactionColumns' +import { AddSsoDomainVerification1787100000000 } from './migration/postgres/1787100000000-AddSsoDomainVerification' const getSslConfig = (): boolean | TlsOptions => { const useSsl = system.get(AppSystemProp.POSTGRES_USE_SSL) @@ -749,6 +750,7 @@ export const getMigrations = (): (new () => Migration)[] => { AddLastLoggedInPlatformIdToUserIdentity1777491000474, ReplacesSandboxWithVercelAiSdk1785000000000, AddChatCompactionColumns1786000000000, + AddSsoDomainVerification1787100000000, ] return migrations } diff --git a/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-controller.ts b/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-controller.ts index 09fab3f8854..77cc909cb80 100644 --- a/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-controller.ts +++ b/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-controller.ts @@ -1,4 +1,4 @@ -import { ApplicationEventName, assertNotNullOrUndefined, SAMLAuthnProviderConfig } from '@activepieces/shared' +import { ApplicationEventName, assertNotNullOrUndefined, PrincipalType, SAMLAuthnProviderConfig } from '@activepieces/shared' import { FastifyBaseLogger, FastifyRequest } from 'fastify' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { z } from 'zod' @@ -9,6 +9,7 @@ import { system } from '../../../helper/system/system' import { AppSystemProp } from '../../../helper/system/system-props' import { platformService } from '../../../platform/platform.service' import { platformUtils } from '../../../platform/platform.utils' +import { platformMustHaveFeatureEnabled } from '../ee-authorization' import { authnSsoSamlService } from './authn-sso-saml-service' export const authnSsoSamlController: FastifyPluginAsyncZod = async (app) => { @@ -39,6 +40,19 @@ export const authnSsoSamlController: FastifyPluginAsyncZod = async (app) => { }) return res.redirect(url.toString()) }) + + app.post('/sso-domain', UpdateSsoDomainRequest, async (req) => { + return authnSsoSamlService(req.log).updateSsoDomain({ + platformId: req.principal.platform.id, + ssoDomain: req.body.ssoDomain, + }) + }) + + app.post('/sso-domain/verify', VerifySsoDomainRequest, async (req) => { + return authnSsoSamlService(req.log).verifySsoDomain({ + platformId: req.principal.platform.id, + }) + }) } async function getSamlConfigOrThrow(req: FastifyRequest, log: FastifyBaseLogger): Promise<{ saml: SAMLAuthnProviderConfig, platformId: string }> { @@ -68,3 +82,22 @@ const LoginRequest = { security: securityAccess.public(), }, } + +const UpdateSsoDomainRequest = { + config: { + security: securityAccess.platformAdminOnly([PrincipalType.USER]), + }, + preHandler: platformMustHaveFeatureEnabled((platform) => platform.plan.ssoEnabled), + schema: { + body: z.object({ + ssoDomain: z.string().max(253).nullable(), + }), + }, +} + +const VerifySsoDomainRequest = { + config: { + security: securityAccess.platformAdminOnly([PrincipalType.USER]), + }, + preHandler: platformMustHaveFeatureEnabled((platform) => platform.plan.ssoEnabled), +} diff --git a/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-module.ts b/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-module.ts index a3b846e84fa..ce391fe3303 100644 --- a/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-module.ts +++ b/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-module.ts @@ -1,6 +1,22 @@ import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' +import { SystemJobName } from '../../../helper/system-jobs/common' +import { systemJobHandlers } from '../../../helper/system-jobs/job-handlers' +import { systemJobsSchedule } from '../../../helper/system-jobs/system-job' import { authnSsoSamlController } from './authn-sso-saml-controller' +import { authnSsoSamlService } from './authn-sso-saml-service' export const authnSsoSamlModule: FastifyPluginAsyncZod = async (app) => { + systemJobHandlers.registerJobHandler(SystemJobName.EXPIRE_PENDING_SSO_DOMAINS, async () => authnSsoSamlService(app.log).expirePendingSsoDomains()) + void systemJobsSchedule(app.log).upsertJob({ + job: { + name: SystemJobName.EXPIRE_PENDING_SSO_DOMAINS, + data: {}, + jobId: SystemJobName.EXPIRE_PENDING_SSO_DOMAINS, + }, + schedule: { + type: 'repeated', + cron: '0 * * * *', + }, + }) await app.register(authnSsoSamlController, { prefix: '/v1/authn/saml' }) } diff --git a/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-service.ts b/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-service.ts index 694fbc577b2..4c55c06323a 100644 --- a/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-service.ts +++ b/packages/server/api/src/app/ee/authentication/saml-authn/authn-sso-saml-service.ts @@ -1,6 +1,9 @@ -import { AuthenticationResponse, SAMLAuthnProviderConfig, UserIdentityProvider } from '@activepieces/shared' +import { resolveTxt } from 'dns/promises' +import { ActivepiecesError, apId, AuthenticationResponse, ErrorCode, isNil, PlatformId, SAMLAuthnProviderConfig, SsoDomainVerification, SsoDomainVerificationRecordType, SsoDomainVerificationStatus, tryCatch, UserIdentityProvider } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' +import { z } from 'zod' import { authenticationService } from '../../../authentication/authentication.service' +import { platformRepo, platformService } from '../../../platform/platform.service' import { createSamlClient, IdpLoginResponse } from './saml-client' export const authnSsoSamlService = (log: FastifyBaseLogger) => { @@ -25,9 +28,124 @@ export const authnSsoSamlService = (log: FastifyBaseLogger) => { predefinedPlatformId: platformId, }) }, + async updateSsoDomain({ platformId, ssoDomain }: UpdateSsoDomainParams): Promise { + const normalized = ssoDomain?.trim().toLowerCase() ?? null + const value = normalized && normalized.length > 0 ? normalized : null + if (!isNil(value)) { + if (!z.hostname().safeParse(value).success || !value.includes('.')) { + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { + message: 'SSO domain must be a valid lowercase domain (e.g. acme.com)', + }, + }) + } + const existing = await platformRepo().findOneBy({ ssoDomain: value }) + if (!isNil(existing) && existing.id !== platformId) { + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { + message: 'This SSO domain is already in use', + }, + }) + } + } + + const current = await platformService(log).getOneOrThrow(platformId) + const verification = computeNextVerification({ nextDomain: value, currentDomain: current.ssoDomain ?? null, currentVerification: current.ssoDomainVerification ?? null }) + + await platformService(log).update({ id: platformId, ssoDomain: value, ssoDomainVerification: verification }) + return { ssoDomain, ssoDomainVerification: verification } + }, + async verifySsoDomain({ platformId }: VerifySsoDomainParams): Promise { + const platform = await platformService(log).getOneOrThrow(platformId) + if (isNil(platform.ssoDomain) || isNil(platform.ssoDomainVerification)) { + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { + message: 'No SSO domain configured for this platform', + }, + }) + } + if (platform.ssoDomainVerification.status === SsoDomainVerificationStatus.VERIFIED) { + return { ssoDomain: platform.ssoDomain, ssoDomainVerification: platform.ssoDomainVerification } + } + + const matched = await txtRecordMatches({ name: platform.ssoDomainVerification.record.name, expected: platform.ssoDomainVerification.record.value, log }) + if (!matched) { + return { ssoDomain: platform.ssoDomain, ssoDomainVerification: platform.ssoDomainVerification } + } + + const verified: SsoDomainVerification = { + ...platform.ssoDomainVerification, + status: SsoDomainVerificationStatus.VERIFIED, + } + const updated = await platformService(log).update({ id: platformId, ssoDomainVerification: verified }) + return { ssoDomain: updated.ssoDomain ?? null, ssoDomainVerification: updated.ssoDomainVerification ?? null } + }, + async expirePendingSsoDomains(): Promise { + const result = await platformRepo() + .createQueryBuilder() + .update() + .set({ ssoDomain: null, ssoDomainVerification: null }) + .where('"ssoDomain" IS NOT NULL') + .andWhere('"ssoDomainVerification"->>\'status\' = :status', { status: SsoDomainVerificationStatus.PENDING_VERIFICATION }) + .andWhere(`("ssoDomainVerification"->>'createdAt')::timestamptz < NOW() - INTERVAL '${PENDING_DOMAIN_TTL_HOURS} hour'`) + .execute() + const affected = result.affected ?? 0 + if (affected > 0) { + log.info({ affected }, 'Expired pending SSO domain verifications') + } + }, + } +} + +const PENDING_DOMAIN_TTL_HOURS = 3 + +function computeNextVerification({ nextDomain, currentDomain, currentVerification }: { nextDomain: string | null, currentDomain: string | null, currentVerification: SsoDomainVerification | null }): SsoDomainVerification | null { + if (isNil(nextDomain)) { + return null + } + if (nextDomain === currentDomain && !isNil(currentVerification)) { + return currentVerification + } + return { + status: SsoDomainVerificationStatus.PENDING_VERIFICATION, + record: { + type: SsoDomainVerificationRecordType.TXT, + name: `${VERIFICATION_NAME_PREFIX}.${nextDomain}`, + value: `${VERIFICATION_VALUE_PREFIX}=${apId()}`, + }, + createdAt: new Date().toISOString(), + } +} + +async function txtRecordMatches({ name, expected, log }: { name: string, expected: string, log: FastifyBaseLogger }): Promise { + const lookup = await tryCatch(() => resolveTxt(name)) + if (lookup.error) { + log.warn({ name, error: lookup.error }, 'TXT record lookup failed for SSO domain verification') + return false } + return lookup.data.some((chunks) => chunks.join('').trim() === expected) } +const VERIFICATION_NAME_PREFIX = '_activepieces-verify' +const VERIFICATION_VALUE_PREFIX = 'activepieces-verify' + type LoginResponse = { redirectUrl: string -} \ No newline at end of file +} + +type UpdateSsoDomainParams = { + platformId: PlatformId + ssoDomain: string | null +} + +type VerifySsoDomainParams = { + platformId: PlatformId +} + +type SsoDomainState = { + ssoDomain: string | null + ssoDomainVerification: SsoDomainVerification | null +} diff --git a/packages/server/api/src/app/helper/system-jobs/common.ts b/packages/server/api/src/app/helper/system-jobs/common.ts index fde02d92ff2..7296ceb8819 100644 --- a/packages/server/api/src/app/helper/system-jobs/common.ts +++ b/packages/server/api/src/app/helper/system-jobs/common.ts @@ -13,6 +13,7 @@ export enum SystemJobName { HARD_DELETE_PROJECT = 'hard-delete-project', HARD_DELETE_PLATFORM = 'hard-delete-platform', RESUME_DELAY_WAITPOINT = 'resume-delay-waitpoint', + EXPIRE_PENDING_SSO_DOMAINS = 'expire-pending-sso-domains', } type DeleteFlowDurableSystemJobData = { @@ -54,6 +55,7 @@ type SystemJobDataMap = { [SystemJobName.HARD_DELETE_PROJECT]: HardDeleteProjectSystemJobData [SystemJobName.HARD_DELETE_PLATFORM]: HardDeletePlatformSystemJobData [SystemJobName.RESUME_DELAY_WAITPOINT]: ResumeDelayWaitpointSystemJobData + [SystemJobName.EXPIRE_PENDING_SSO_DOMAINS]: Record } export type SystemJobData = T extends SystemJobName ? SystemJobDataMap[T] : never diff --git a/packages/server/api/src/app/platform/platform.entity.ts b/packages/server/api/src/app/platform/platform.entity.ts index 6165adada7b..aad239394f5 100644 --- a/packages/server/api/src/app/platform/platform.entity.ts +++ b/packages/server/api/src/app/platform/platform.entity.ts @@ -56,6 +56,14 @@ export const PlatformEntity = new EntitySchema({ type: String, array: true, }, + ssoDomain: { + type: String, + nullable: true, + }, + ssoDomainVerification: { + type: 'jsonb', + nullable: true, + }, enforceAllowedAuthDomains: { type: Boolean, nullable: false, @@ -73,7 +81,14 @@ export const PlatformEntity = new EntitySchema({ nullable: false, }, }, - indices: [], + indices: [ + { + name: 'idx_platform_sso_domain', + columns: ['ssoDomain'], + unique: true, + where: '"ssoDomain" IS NOT NULL', + }, + ], relations: { owner: { type: 'one-to-one', diff --git a/packages/server/api/src/app/platform/platform.service.ts b/packages/server/api/src/app/platform/platform.service.ts index 2bf17bcbd3b..08c4deaba89 100644 --- a/packages/server/api/src/app/platform/platform.service.ts +++ b/packages/server/api/src/app/platform/platform.service.ts @@ -16,6 +16,7 @@ import { ActivepiecesError, PlatformWithoutSensitiveData, ProjectType, spreadIfDefined, + SsoDomainVerification, UpdatePlatformRequestBody, UserId, UserStatus, @@ -149,6 +150,8 @@ export const platformService = (log: FastifyBaseLogger) => ({ params.enforceAllowedAuthDomains, ), ...spreadIfDefined('allowedAuthDomains', params.allowedAuthDomains), + ...spreadIfDefined('ssoDomain', params.ssoDomain), + ...spreadIfDefined('ssoDomainVerification', params.ssoDomainVerification), ...spreadIfDefined('pinnedPieces', params.pinnedPieces), } if (!isNil(params.plan)) { @@ -266,6 +269,8 @@ type UpdateParams = UpdatePlatformRequestBody & { logoIconUrl?: string fullLogoUrl?: string favIconUrl?: string + ssoDomain?: string | null + ssoDomainVerification?: SsoDomainVerification | null } type CreatePlatformWithProjectParams = { diff --git a/packages/server/api/test/integration/cloud/platform/platform.test.ts b/packages/server/api/test/integration/cloud/platform/platform.test.ts index 2fdea54f04e..6da06ccb20a 100644 --- a/packages/server/api/test/integration/cloud/platform/platform.test.ts +++ b/packages/server/api/test/integration/cloud/platform/platform.test.ts @@ -338,7 +338,7 @@ describe('Platform API', () => { // assert expect(response?.statusCode).toBe(StatusCodes.OK) - expect(Object.keys(responseBody).length).toBe(19) + expect(Object.keys(responseBody).length).toBe(21) expect(responseBody.id).toBe(mockPlatform.id) expect(responseBody.ownerId).toBe(mockOwner.id) expect(responseBody.name).toBe(mockPlatform.name) diff --git a/packages/shared/package.json b/packages/shared/package.json index c7f45ff03f0..3798a779700 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.69.0", + "version": "0.70.1", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/shared/src/lib/management/platform/index.ts b/packages/shared/src/lib/management/platform/index.ts index 699b8cba76e..4c20195fc1a 100644 --- a/packages/shared/src/lib/management/platform/index.ts +++ b/packages/shared/src/lib/management/platform/index.ts @@ -1,3 +1,4 @@ export * from './platform.model' export * from './platform.request' export * from './concurrency-pool' +export * from './sso-domain-verification' diff --git a/packages/shared/src/lib/management/platform/platform.model.ts b/packages/shared/src/lib/management/platform/platform.model.ts index 1987511a19b..dd92ad849a1 100644 --- a/packages/shared/src/lib/management/platform/platform.model.ts +++ b/packages/shared/src/lib/management/platform/platform.model.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { BaseModelSchema, DateOrString, Nullable } from '../../core/common/base-model' import { ApId } from '../../core/common/id-generator' import { FederatedAuthnProviderConfig, FederatedAuthnProviderConfigWithoutSensitiveData } from '../../core/federated-authn' +import { SsoDomainVerification } from './sso-domain-verification' export type PlatformId = ApId @@ -127,6 +128,8 @@ export const Platform = z.object({ cloudAuthEnabled: z.boolean(), enforceAllowedAuthDomains: z.boolean(), allowedAuthDomains: z.array(z.string()), + ssoDomain: Nullable(z.string()), + ssoDomainVerification: Nullable(SsoDomainVerification), federatedAuthProviders: FederatedAuthnProviderConfig, emailAuthEnabled: z.boolean(), pinnedPieces: z.array(z.string()), @@ -151,6 +154,8 @@ export const PlatformWithoutSensitiveData = z.object({ cloudAuthEnabled: z.boolean(), enforceAllowedAuthDomains: z.boolean(), allowedAuthDomains: z.array(z.string()), + ssoDomain: Nullable(z.string()), + ssoDomainVerification: Nullable(SsoDomainVerification), emailAuthEnabled: z.boolean(), pinnedPieces: z.array(z.string()), }) diff --git a/packages/shared/src/lib/management/platform/sso-domain-verification.ts b/packages/shared/src/lib/management/platform/sso-domain-verification.ts new file mode 100644 index 00000000000..e8431395e68 --- /dev/null +++ b/packages/shared/src/lib/management/platform/sso-domain-verification.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' + +export enum SsoDomainVerificationStatus { + PENDING_VERIFICATION = 'PENDING_VERIFICATION', + VERIFIED = 'VERIFIED', +} + +export enum SsoDomainVerificationRecordType { + TXT = 'TXT', +} + +export const SsoDomainVerificationRecord = z.object({ + type: z.enum([SsoDomainVerificationRecordType.TXT]), + name: z.string(), + value: z.string(), +}) + +export type SsoDomainVerificationRecord = z.infer + +export const SsoDomainVerification = z.object({ + status: z.enum([ + SsoDomainVerificationStatus.PENDING_VERIFICATION, + SsoDomainVerificationStatus.VERIFIED, + ]), + record: SsoDomainVerificationRecord, + createdAt: z.string(), +}) + +export type SsoDomainVerification = z.infer diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 07d30761512..266bda5948e 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -1576,5 +1576,27 @@ "What would you like to do in {projectName}?": "What would you like to do in {projectName}?", "steps": "steps", "{name} connected": "{name} connected", - "AI Chat requires an E2B sandbox to run. Ask your admin to set the AP_E2B_API_KEY environment variable.": "AI Chat requires an E2B sandbox to run. Ask your admin to set the AP_E2B_API_KEY environment variable." + "AI Chat requires an E2B sandbox to run. Ask your admin to set the AP_E2B_API_KEY environment variable.": "AI Chat requires an E2B sandbox to run. Ask your admin to set the AP_E2B_API_KEY environment variable.", + "SSO Domain": "SSO Domain", + "Maps an email domain to your SAML provider.": "Maps an email domain to your SAML provider.", + "Domain": "Domain", + "Configure": "Configure", + "Manage": "Manage", + "Verified": "Verified", + "Pending verification": "Pending verification", + "Save domain": "Save domain", + "Update domain": "Update domain", + "Update SSO domain?": "Update SSO domain?", + "Users won't be able to sign in via SSO until you verify the new domain.": "Users won't be able to sign in via SSO until you verify the new domain.", + "SSO domain saved": "SSO domain saved", + "Domain verified": "Domain verified", + "Couldn't save domain": "Couldn't save domain", + "Couldn't verify domain": "Couldn't verify domain", + "Verify DNS": "Verify DNS", + "DNS verified — domain is ready": "DNS verified — domain is ready", + "Waiting for DNS": "Waiting for DNS", + "TXT record not found yet — DNS can take a few minutes.": "TXT record not found yet — DNS can take a few minutes.", + "Add this TXT record at your DNS provider. We'll detect it once it propagates — this usually takes a few minutes.": "Add this TXT record at your DNS provider. We'll detect it once it propagates — this usually takes a few minutes.", + "When a user enters this domain on the sign-in page, they will be redirected to your SAML identity provider.": "When a user enters this domain on the sign-in page, they will be redirected to your SAML identity provider.", + "invalidSsoDomain": "Must be a valid lowercase domain (e.g. acme.com)" } diff --git a/packages/web/src/app/routes/platform/security/sso/index.tsx b/packages/web/src/app/routes/platform/security/sso/index.tsx index 05f0885f563..bec08be7066 100644 --- a/packages/web/src/app/routes/platform/security/sso/index.tsx +++ b/packages/web/src/app/routes/platform/security/sso/index.tsx @@ -1,6 +1,6 @@ -import { isNil } from '@activepieces/shared'; +import { isNil, SsoDomainVerificationStatus } from '@activepieces/shared'; import { t } from 'i18next'; -import { LockIcon, MailIcon, Earth } from 'lucide-react'; +import { CheckCircle, Globe, LockIcon, MailIcon, Earth } from 'lucide-react'; import { toast } from 'sonner'; import { CenteredPage } from '@/app/components/centered-page'; @@ -8,6 +8,7 @@ import LockedFeatureGuard from '@/app/components/locked-feature-guard'; import { AllowedDomainDialog } from '@/app/routes/platform/security/sso/allowed-domain'; import { NewOAuth2Dialog } from '@/app/routes/platform/security/sso/oauth2-dialog'; import { ConfigureSamlDialog } from '@/app/routes/platform/security/sso/saml-dialog'; +import { SsoDomainDialog } from '@/app/routes/platform/security/sso/sso-domain-dialog'; import { Item, ItemMedia, @@ -28,6 +29,9 @@ const SSOPage = () => { const googleConnected = !isNil(platform.federatedAuthProviders?.google); const samlConnected = !isNil(platform.federatedAuthProviders?.saml); + const ssoDomainVerified = + platform.ssoDomainVerification?.status === + SsoDomainVerificationStatus.VERIFIED; const emailAuthEnabled = platform.emailAuthEnabled; const { mutate: toggleEmailAuthentication, isPending } = @@ -121,6 +125,36 @@ const SSOPage = () => { + + + + + + {t('SSO Domain')} + + {t('Maps an email domain to your SAML provider.')} + + {platform.ssoDomain && ( +
+ {platform.ssoDomain} + {ssoDomainVerified ? ( + + + {t('Verified')} + + ) : ( + + {t('Pending verification')} + + )} +
+ )} +
+ + + +
+ diff --git a/packages/web/src/app/routes/platform/security/sso/sso-domain-dialog.tsx b/packages/web/src/app/routes/platform/security/sso/sso-domain-dialog.tsx new file mode 100644 index 00000000000..802a1472706 --- /dev/null +++ b/packages/web/src/app/routes/platform/security/sso/sso-domain-dialog.tsx @@ -0,0 +1,349 @@ +import { + ApErrorParams, + PlatformWithoutSensitiveData, + SsoDomainVerification, + SsoDomainVerificationRecord, + SsoDomainVerificationStatus, +} from '@activepieces/shared'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@tanstack/react-query'; +import { t } from 'i18next'; +import { CheckCircle, Loader2, TriangleAlert } from 'lucide-react'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +import { CopyToClipboardInput } from '@/components/custom/clipboard/copy-to-clipboard'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + Form, + FormDescription, + FormField, + FormItem, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { samlSsoApi } from '@/features/platform-admin'; +import { api } from '@/lib/api'; + +type SsoDomainDialogProps = { + platform: PlatformWithoutSensitiveData; + refetch: () => Promise; +}; + +export const SsoDomainDialog = ({ + platform, + refetch, +}: SsoDomainDialogProps) => { + const [open, setOpen] = useState(false); + const hasDomain = !!platform.ssoDomain; + + return ( + + + + + + {open && ( + setOpen(false)} + /> + )} + + + ); +}; + +const SsoDomainFormValues = z.object({ + ssoDomain: z + .hostname('invalidSsoDomain') + .max(253, 'invalidSsoDomain') + .refine((v) => v.includes('.'), 'invalidSsoDomain'), +}); +type SsoDomainFormValues = z.infer; + +const SsoDomainForm = ({ + platform, + refetch, + onClose, +}: { + platform: PlatformWithoutSensitiveData; + refetch: () => Promise; + onClose: () => void; +}) => { + const verification = platform.ssoDomainVerification ?? null; + + const form = useForm({ + resolver: zodResolver(SsoDomainFormValues), + defaultValues: { ssoDomain: platform.ssoDomain ?? '' }, + mode: 'onChange', + }); + + const ssoDomainValue = form.watch('ssoDomain'); + const isDirty = + ssoDomainValue.trim().toLowerCase() !== (platform.ssoDomain ?? ''); + + const [showUpdateWarning, setShowUpdateWarning] = useState(false); + + const { mutate: saveDomain, isPending: isSaving } = useMutation({ + mutationFn: async (values: SsoDomainFormValues) => { + await samlSsoApi.updateSsoDomain(values.ssoDomain.trim().toLowerCase()); + await refetch(); + }, + onSuccess: () => { + toast.success(t('SSO domain saved')); + setShowUpdateWarning(false); + }, + onError: (error) => { + form.setError('root.serverError', { + type: 'manual', + message: extractServerErrorMessage(error, t("Couldn't save domain")), + }); + setShowUpdateWarning(false); + }, + }); + + const { mutate: verifyDomain, isPending: isVerifying } = useMutation({ + mutationFn: async () => { + const result = await samlSsoApi.verifySsoDomain(); + await refetch(); + return result; + }, + onSuccess: (result) => { + if ( + result.ssoDomainVerification?.status === + SsoDomainVerificationStatus.VERIFIED + ) { + toast.success(t('Domain verified')); + onClose(); + } else { + toast.message( + t('TXT record not found yet — DNS can take a few minutes.'), + ); + } + }, + onError: (error) => { + toast.error( + extractServerErrorMessage(error, t("Couldn't verify domain")), + ); + }, + }); + + const handleSubmit = (values: SsoDomainFormValues) => { + if (platform.ssoDomain) { + setShowUpdateWarning(true); + return; + } + saveDomain(values); + }; + + return ( + <> + + {t('SSO Domain')} + +
+ + ( + + + + + {t( + 'When a user enters this domain on the sign-in page, they will be redirected to your SAML identity provider.', + )} + + + + )} + /> + + {verification && !isDirty && ( + verifyDomain()} + /> + )} + + {form.formState.errors.root?.serverError && ( + + {form.formState.errors.root.serverError.message} + + )} + + + + + + + + + + + {t('Update SSO domain?')} + + + + + {t( + "Users won't be able to sign in via SSO until you verify the new domain.", + )} + + + + + + + + + + ); +}; + +const DomainVerificationPanel = ({ + verification, + isVerifying, + onVerify, +}: { + verification: SsoDomainVerification; + isVerifying: boolean; + onVerify: () => void; +}) => { + const verified = verification.status === SsoDomainVerificationStatus.VERIFIED; + return ( +
+ + {!verified && ( + <> +

+ {t( + "Add this TXT record at your DNS provider. We'll detect it once it propagates — this usually takes a few minutes.", + )} +

+ +
+ +
+ + )} +
+ ); +}; + +const VerificationStatusBadge = ({ + status, +}: { + status: SsoDomainVerificationStatus; +}) => { + if (status === SsoDomainVerificationStatus.VERIFIED) { + return ( +
+ + {t('DNS verified — domain is ready')} +
+ ); + } + return ( +
+ + {t('Waiting for DNS')} +
+ ); +}; + +const VerificationRecordRow = ({ + record, +}: { + record: SsoDomainVerificationRecord; +}) => ( +
+
+ + {record.type} + +
+
+
+ + +
+
+ + +
+
+
+); + +function extractServerErrorMessage(error: unknown, fallback: string): string { + if (api.isError(error)) { + const data = error.response?.data as ApErrorParams | undefined; + const message = + data?.params && 'message' in data.params + ? data.params.message + : undefined; + if (typeof message === 'string' && message.length > 0) { + return message; + } + } + if (error instanceof Error && error.message.length > 0) { + return error.message; + } + return fallback; +} diff --git a/packages/web/src/features/platform-admin/api/saml-sso-api.ts b/packages/web/src/features/platform-admin/api/saml-sso-api.ts new file mode 100644 index 00000000000..d5197a524f8 --- /dev/null +++ b/packages/web/src/features/platform-admin/api/saml-sso-api.ts @@ -0,0 +1,19 @@ +import { SsoDomainVerification } from '@activepieces/shared'; + +import { api } from '@/lib/api'; + +type SsoDomainState = { + ssoDomain: string | null; + ssoDomainVerification: SsoDomainVerification | null; +}; + +export const samlSsoApi = { + updateSsoDomain(ssoDomain: string | null) { + return api.post('/v1/authn/saml/sso-domain', { + ssoDomain, + }); + }, + verifySsoDomain() { + return api.post('/v1/authn/saml/sso-domain/verify', {}); + }, +}; diff --git a/packages/web/src/features/platform-admin/index.ts b/packages/web/src/features/platform-admin/index.ts index 6ae2a772d8f..3872e4bb8d3 100644 --- a/packages/web/src/features/platform-admin/index.ts +++ b/packages/web/src/features/platform-admin/index.ts @@ -4,6 +4,7 @@ export { apiKeyApi } from './api/api-key-api'; export { auditEventsApi } from './api/audit-events-api'; export { piecesTagsApi } from './api/pieces-tags'; export { projectRoleApi } from './api/project-role-api'; +export { samlSsoApi } from './api/saml-sso-api'; export { signingKeyApi } from './api/signing-key-api'; export { workersApi } from './api/workers-api'; export { NewSigningKeyDialog } from './components/new-signing-key-dialog';