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:
Waleed
2025-10-25 14:58:25 -07:00
committed by GitHub
parent 517f1a91b6
commit ce4893a53c
12 changed files with 334 additions and 17 deletions

View File

@@ -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) {

View File

@@ -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
}
}

View File

@@ -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(),

View File

@@ -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' }
}

View File

@@ -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

View 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>
)
}

View File

@@ -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'

View File

@@ -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)

View File

@@ -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>

View File

@@ -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

View File

@@ -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'
}

View File

@@ -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