Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/sim/app/(auth)/sso/sso-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/chat/manage/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Expand Down
9 changes: 8 additions & 1 deletion apps/sim/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
62 changes: 61 additions & 1 deletion apps/sim/app/api/chat/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
}

Expand Down
19 changes: 17 additions & 2 deletions apps/sim/app/chat/[identifier]/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 }>
}

Expand Down Expand Up @@ -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 } =
Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -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
Expand Down
209 changes: 209 additions & 0 deletions apps/sim/app/chat/components/auth/sso/sso-auth.tsx
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions apps/sim/app/chat/components/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
Loading