From cc1296547ef276cb2000bf7d4416b7fc33b06b9f Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 25 Oct 2025 14:04:06 -0700 Subject: [PATCH 1/2] feat(sso-chat-deployment): added sso auth option for chat deployment --- apps/sim/app/(auth)/sso/sso-form.tsx | 6 + apps/sim/app/api/chat/manage/[id]/route.ts | 4 +- apps/sim/app/api/chat/route.ts | 9 +- apps/sim/app/api/chat/utils.ts | 62 +++++- apps/sim/app/chat/[identifier]/chat.tsx | 19 +- .../app/chat/components/auth/sso/sso-auth.tsx | 209 ++++++++++++++++++ apps/sim/app/chat/components/index.ts | 1 + .../components/chat-deploy/chat-deploy.tsx | 2 +- .../chat-deploy/components/auth-selector.tsx | 24 +- .../chat-deploy/hooks/use-chat-deployment.ts | 3 +- .../chat-deploy/hooks/use-chat-form.ts | 6 +- 11 files changed, 331 insertions(+), 14 deletions(-) create mode 100644 apps/sim/app/chat/components/auth/sso/sso-auth.tsx diff --git a/apps/sim/app/(auth)/sso/sso-form.tsx b/apps/sim/app/(auth)/sso/sso-form.tsx index 29784afa9f..0a6dab76e6 100644 --- a/apps/sim/app/(auth)/sso/sso-form.tsx +++ b/apps/sim/app/(auth)/sso/sso-form.tsx @@ -71,6 +71,12 @@ export default function SSOForm() { } } + // Pre-fill email if provided in URL (e.g., from deployed chat SSO) + const emailParam = searchParams.get('email') + if (emailParam) { + setEmail(emailParam) + } + // Check for SSO error from redirect const error = searchParams.get('error') if (error) { diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 65d0583ac0..45fa151cc6 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -31,7 +31,7 @@ const chatUpdateSchema = z.object({ imageUrl: z.string().optional(), }) .optional(), - authType: z.enum(['public', 'password', 'email']).optional(), + authType: z.enum(['public', 'password', 'email', 'sso']).optional(), password: z.string().optional(), allowedEmails: z.array(z.string()).optional(), outputConfigs: z @@ -165,7 +165,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< updateData.allowedEmails = [] } else if (authType === 'password') { updateData.allowedEmails = [] - } else if (authType === 'email') { + } else if (authType === 'email' || authType === 'sso') { updateData.password = null } } diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index fb51159bb2..9f2180087b 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -27,7 +27,7 @@ const chatSchema = z.object({ welcomeMessage: z.string(), imageUrl: z.string().optional(), }), - authType: z.enum(['public', 'password', 'email']).default('public'), + authType: z.enum(['public', 'password', 'email', 'sso']).default('public'), password: z.string().optional(), allowedEmails: z.array(z.string()).optional().default([]), outputConfigs: z @@ -98,6 +98,13 @@ export async function POST(request: NextRequest) { ) } + if (authType === 'sso' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) { + return createErrorResponse( + 'At least one email or domain is required when using SSO access control', + 400 + ) + } + // Check if identifier is available const existingIdentifier = await db .select() diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 060f522ba7..2b1f9dbe30 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -262,7 +262,67 @@ export async function validateChatAuth( } } - // Unknown auth type + if (authType === 'sso') { + if (request.method === 'GET') { + return { authorized: false, error: 'auth_required_sso' } + } + + try { + if (!parsedBody) { + return { authorized: false, error: 'SSO authentication is required' } + } + + const { email, input, checkSSOAccess } = parsedBody + + if (checkSSOAccess) { + if (!email) { + return { authorized: false, error: 'Email is required' } + } + + const allowedEmails = deployment.allowedEmails || [] + + if (allowedEmails.includes(email)) { + return { authorized: true } + } + + const domain = email.split('@')[1] + if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) { + return { authorized: true } + } + + return { authorized: false, error: 'Email not authorized for SSO access' } + } + + const { auth } = await import('@/lib/auth') + const session = await auth.api.getSession({ headers: request.headers }) + + if (!session || !session.user) { + return { authorized: false, error: 'auth_required_sso' } + } + + const userEmail = session.user.email + if (!userEmail) { + return { authorized: false, error: 'SSO session does not contain email' } + } + + const allowedEmails = deployment.allowedEmails || [] + + if (allowedEmails.includes(userEmail)) { + return { authorized: true } + } + + const domain = userEmail.split('@')[1] + if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) { + return { authorized: true } + } + + return { authorized: false, error: 'Your email is not authorized to access this chat' } + } catch (error) { + logger.error(`[${requestId}] Error validating SSO:`, error) + return { authorized: false, error: 'SSO authentication error' } + } + } + return { authorized: false, error: 'Unsupported authentication type' } } diff --git a/apps/sim/app/chat/[identifier]/chat.tsx b/apps/sim/app/chat/[identifier]/chat.tsx index cb940f7568..47cb35fe32 100644 --- a/apps/sim/app/chat/[identifier]/chat.tsx +++ b/apps/sim/app/chat/[identifier]/chat.tsx @@ -14,6 +14,7 @@ import { ChatMessageContainer, EmailAuth, PasswordAuth, + SSOAuth, VoiceInterface, } from '@/app/chat/components' import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/chat/constants' @@ -32,7 +33,7 @@ interface ChatConfig { welcomeMessage?: string headerText?: string } - authType?: 'public' | 'password' | 'email' + authType?: 'public' | 'password' | 'email' | 'sso' outputConfigs?: Array<{ blockId: string; path?: string }> } @@ -119,7 +120,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { const [userHasScrolled, setUserHasScrolled] = useState(false) const isUserScrollingRef = useRef(false) - const [authRequired, setAuthRequired] = useState<'password' | 'email' | null>(null) + const [authRequired, setAuthRequired] = useState<'password' | 'email' | 'sso' | null>(null) const [isVoiceFirstMode, setIsVoiceFirstMode] = useState(false) const { isStreamingResponse, abortControllerRef, stopStreaming, handleStreamedResponse } = @@ -222,6 +223,10 @@ export default function ChatClient({ identifier }: { identifier: string }) { setAuthRequired('email') return } + if (errorData.error === 'auth_required_sso') { + setAuthRequired('sso') + return + } } throw new Error(`Failed to load chat configuration: ${response.status}`) @@ -500,6 +505,16 @@ export default function ChatClient({ identifier }: { identifier: string }) { /> ) } + if (authRequired === 'sso') { + return ( + + ) + } } // Loading state while fetching config using the extracted component diff --git a/apps/sim/app/chat/components/auth/sso/sso-auth.tsx b/apps/sim/app/chat/components/auth/sso/sso-auth.tsx new file mode 100644 index 0000000000..6985a5f596 --- /dev/null +++ b/apps/sim/app/chat/components/auth/sso/sso-auth.tsx @@ -0,0 +1,209 @@ +'use client' + +import { type KeyboardEvent, useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { quickValidateEmail } from '@/lib/email/validation' +import { createLogger } from '@/lib/logs/console/logger' +import { cn } from '@/lib/utils' +import Nav from '@/app/(landing)/components/nav/nav' +import { inter } from '@/app/fonts/inter' +import { soehne } from '@/app/fonts/soehne/soehne' + +const logger = createLogger('SSOAuth') + +interface SSOAuthProps { + identifier: string + onAuthSuccess: () => void + title?: string + primaryColor?: string +} + +const validateEmailField = (emailValue: string): string[] => { + const errors: string[] = [] + + if (!emailValue || !emailValue.trim()) { + errors.push('Email is required.') + return errors + } + + const validation = quickValidateEmail(emailValue.trim().toLowerCase()) + if (!validation.isValid) { + errors.push(validation.reason || 'Please enter a valid email address.') + } + + return errors +} + +export default function SSOAuth({ + identifier, + onAuthSuccess, + title = 'chat', + primaryColor = 'var(--brand-primary-hover-hex)', +}: SSOAuthProps) { + const router = useRouter() + const [email, setEmail] = useState('') + const [emailErrors, setEmailErrors] = useState([]) + const [showEmailValidationError, setShowEmailValidationError] = useState(false) + const [buttonClass, setButtonClass] = useState('auth-button-gradient') + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + const checkCustomBrand = () => { + const computedStyle = getComputedStyle(document.documentElement) + const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim() + + if (brandAccent && brandAccent !== '#6f3dfa') { + setButtonClass('auth-button-custom') + } else { + setButtonClass('auth-button-gradient') + } + } + + checkCustomBrand() + + window.addEventListener('resize', checkCustomBrand) + const observer = new MutationObserver(checkCustomBrand) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['style', 'class'], + }) + + return () => { + window.removeEventListener('resize', checkCustomBrand) + observer.disconnect() + } + }, []) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleAuthenticate() + } + } + + const handleEmailChange = (e: React.ChangeEvent) => { + const newEmail = e.target.value + setEmail(newEmail) + setShowEmailValidationError(false) + setEmailErrors([]) + } + + const handleAuthenticate = async () => { + const emailValidationErrors = validateEmailField(email) + setEmailErrors(emailValidationErrors) + setShowEmailValidationError(emailValidationErrors.length > 0) + + if (emailValidationErrors.length > 0) { + return + } + + setIsLoading(true) + + try { + const checkResponse = await fetch(`/api/chat/${identifier}`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ email, checkSSOAccess: true }), + }) + + if (!checkResponse.ok) { + const errorData = await checkResponse.json() + setEmailErrors([errorData.error || 'Email not authorized for this chat']) + setShowEmailValidationError(true) + setIsLoading(false) + return + } + + const callbackUrl = `/chat/${identifier}` + const ssoUrl = `/sso?email=${encodeURIComponent(email)}&callbackUrl=${encodeURIComponent(callbackUrl)}` + router.push(ssoUrl) + } catch (error) { + logger.error('SSO authentication error:', error) + setEmailErrors(['An error occurred during authentication']) + setShowEmailValidationError(true) + setIsLoading(false) + } + } + + return ( +
+
+ ) +} diff --git a/apps/sim/app/chat/components/index.ts b/apps/sim/app/chat/components/index.ts index eef5a82c46..4be7ea2f19 100644 --- a/apps/sim/app/chat/components/index.ts +++ b/apps/sim/app/chat/components/index.ts @@ -1,5 +1,6 @@ export { default as EmailAuth } from './auth/email/email-auth' export { default as PasswordAuth } from './auth/password/password-auth' +export { default as SSOAuth } from './auth/sso/sso-auth' export { ChatErrorState } from './error-state/error-state' export { ChatHeader } from './header/header' export { ChatInput } from './input/input' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx index 0a70a2f149..43ad1bad39 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx @@ -104,7 +104,7 @@ export function ChatDeploy({ (formData.authType !== 'password' || Boolean(formData.password.trim()) || Boolean(existingChat)) && - (formData.authType !== 'email' || formData.emails.length > 0) + ((formData.authType !== 'email' && formData.authType !== 'sso') || formData.emails.length > 0) useEffect(() => { onValidationChange?.(isFormValid) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx index d377036659..497d39129f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { Check, Copy, Eye, EyeOff, Plus, RefreshCw, Trash2 } from 'lucide-react' import { Button, Card, CardContent, Input, Label } from '@/components/ui' +import { getEnv, isTruthy } from '@/lib/env' import { cn, generatePassword } from '@/lib/utils' import type { AuthType } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form' @@ -63,13 +64,20 @@ export function AuthSelector({ onEmailsChange(emails.filter((e) => e !== email)) } + const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) + const authOptions = ssoEnabled + ? (['public', 'password', 'email', 'sso'] as const) + : (['public', 'password', 'email'] as const) + return (
{/* Auth Type Selection */} -
- {(['public', 'password', 'email'] as const).map((type) => ( +
+ {authOptions.map((type) => (

{type === 'public' && 'Anyone can access your chat'} {type === 'password' && 'Secure with a single password'} {type === 'email' && 'Restrict to specific emails'} + {type === 'sso' && 'Authenticate via SSO provider'}

@@ -207,10 +217,12 @@ export function AuthSelector({ )} - {authType === 'email' && ( + {(authType === 'email' || authType === 'sso') && ( -

Email Access Settings

+

+ {authType === 'email' ? 'Email Access Settings' : 'SSO Access Settings'} +

- Add specific emails or entire domains (@example.com) + {authType === 'email' + ? 'Add specific emails or entire domains (@example.com)' + : 'Add specific emails or entire domains (@example.com) that can access via SSO'}

diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment.ts index 2d03c0c903..7552e29337 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment.ts @@ -85,7 +85,8 @@ export function useChatDeployment() { }, authType: formData.authType, password: formData.authType === 'password' ? formData.password : undefined, - allowedEmails: formData.authType === 'email' ? formData.emails : [], + allowedEmails: + formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [], outputConfigs, apiKey: deploymentInfo?.apiKey, deployApiEnabled: !existingChatId, // Only deploy API for new chats diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form.ts index 753662453f..3c73581d74 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form.ts @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react' -export type AuthType = 'public' | 'password' | 'email' +export type AuthType = 'public' | 'password' | 'email' | 'sso' export interface ChatFormData { identifier: string @@ -85,6 +85,10 @@ export function useChatForm(initialData?: Partial) { newErrors.emails = 'At least one email or domain is required when using email access control' } + if (formData.authType === 'sso' && formData.emails.length === 0) { + newErrors.emails = 'At least one email or domain is required when using SSO access control' + } + if (formData.selectedOutputBlocks.length === 0) { newErrors.outputBlocks = 'Please select at least one output block' } From cb14a5ba438251a7bf6036403e8cbc79ef2c2050 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 25 Oct 2025 14:44:58 -0700 Subject: [PATCH 2/2] ack PR comments --- apps/sim/app/api/chat/route.ts | 2 +- packages/db/schema.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index 9f2180087b..173c6c89a4 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -170,7 +170,7 @@ export async function POST(request: NextRequest) { isActive: true, authType, password: encryptedPassword, - allowedEmails: authType === 'email' ? allowedEmails : [], + allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], outputConfigs, createdAt: new Date(), updatedAt: new Date(), diff --git a/packages/db/schema.ts b/packages/db/schema.ts index afcf756f8a..1c026d2540 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -648,9 +648,9 @@ export const chat = pgTable( customizations: json('customizations').default('{}'), // For UI customization options // Authentication options - authType: text('auth_type').notNull().default('public'), // 'public', 'password', 'email' + authType: text('auth_type').notNull().default('public'), // 'public', 'password', 'email', 'sso' password: text('password'), // Stored hashed, populated when authType is 'password' - allowedEmails: json('allowed_emails').default('[]'), // Array of allowed emails or domains when authType is 'email' + allowedEmails: json('allowed_emails').default('[]'), // Array of allowed emails or domains when authType is 'email' or 'sso' // Output configuration outputConfigs: json('output_configs').default('[]'), // Array of {blockId, path} objects