Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
11 changes: 9 additions & 2 deletions 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 Expand Up @@ -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(),
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>
)
}
Loading