mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
feat(sso-chat-deployment): added sso auth option for chat deployment (#1729)
* feat(sso-chat-deployment): added sso auth option for chat deployment * ack PR comments
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -163,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(),
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<SSOAuth
|
||||
identifier={identifier}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
title={title}
|
||||
primaryColor={primaryColor}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state while fetching config using the extracted component
|
||||
|
||||
209
apps/sim/app/chat/components/auth/sso/sso-auth.tsx
Normal file
209
apps/sim/app/chat/components/auth/sso/sso-auth.tsx
Normal file
@@ -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<string[]>([])
|
||||
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<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAuthenticate()
|
||||
}
|
||||
}
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className='bg-white'>
|
||||
<Nav variant='auth' />
|
||||
<div className='flex min-h-[calc(100vh-120px)] items-center justify-center px-4'>
|
||||
<div className='w-full max-w-[410px]'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
{/* Header */}
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1
|
||||
className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
|
||||
>
|
||||
SSO Authentication
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
This chat requires SSO authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleAuthenticate()
|
||||
}}
|
||||
className={`${inter.className} mt-8 w-full space-y-8`}
|
||||
>
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='email'>Work Email</Label>
|
||||
</div>
|
||||
<Input
|
||||
id='email'
|
||||
name='email'
|
||||
required
|
||||
type='email'
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
placeholder='Enter your work email'
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
showEmailValidationError &&
|
||||
emailErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
{showEmailValidationError && emailErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{emailErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Redirecting to SSO...' : 'Continue with SSO'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
<div className='space-y-2'>
|
||||
<Label className='font-medium text-sm'>Access Control</Label>
|
||||
|
||||
{/* Auth Type Selection */}
|
||||
<div className='grid grid-cols-1 gap-3 md:grid-cols-3'>
|
||||
{(['public', 'password', 'email'] as const).map((type) => (
|
||||
<div
|
||||
className={cn('grid grid-cols-1 gap-3', ssoEnabled ? 'md:grid-cols-4' : 'md:grid-cols-3')}
|
||||
>
|
||||
{authOptions.map((type) => (
|
||||
<Card
|
||||
key={type}
|
||||
className={cn(
|
||||
@@ -92,11 +100,13 @@ export function AuthSelector({
|
||||
{type === 'public' && 'Public Access'}
|
||||
{type === 'password' && 'Password Protected'}
|
||||
{type === 'email' && 'Email Access'}
|
||||
{type === 'sso' && 'SSO Access'}
|
||||
</h3>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{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'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -207,10 +217,12 @@ export function AuthSelector({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{authType === 'email' && (
|
||||
{(authType === 'email' || authType === 'sso') && (
|
||||
<Card className='rounded-[8px] shadow-none'>
|
||||
<CardContent className='p-4'>
|
||||
<h3 className='mb-2 font-medium text-sm'>Email Access Settings</h3>
|
||||
<h3 className='mb-2 font-medium text-sm'>
|
||||
{authType === 'email' ? 'Email Access Settings' : 'SSO Access Settings'}
|
||||
</h3>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
@@ -264,7 +276,9 @@ export function AuthSelector({
|
||||
)}
|
||||
|
||||
<p className='mt-2 text-muted-foreground text-xs'>
|
||||
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'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ChatFormData>) {
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user