Skip to content

Commit ce4893a

Browse files
authored
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
1 parent 517f1a9 commit ce4893a

File tree

12 files changed

+334
-17
lines changed

12 files changed

+334
-17
lines changed

apps/sim/app/(auth)/sso/sso-form.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ export default function SSOForm() {
7171
}
7272
}
7373

74+
// Pre-fill email if provided in URL (e.g., from deployed chat SSO)
75+
const emailParam = searchParams.get('email')
76+
if (emailParam) {
77+
setEmail(emailParam)
78+
}
79+
7480
// Check for SSO error from redirect
7581
const error = searchParams.get('error')
7682
if (error) {

apps/sim/app/api/chat/manage/[id]/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const chatUpdateSchema = z.object({
3131
imageUrl: z.string().optional(),
3232
})
3333
.optional(),
34-
authType: z.enum(['public', 'password', 'email']).optional(),
34+
authType: z.enum(['public', 'password', 'email', 'sso']).optional(),
3535
password: z.string().optional(),
3636
allowedEmails: z.array(z.string()).optional(),
3737
outputConfigs: z
@@ -165,7 +165,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
165165
updateData.allowedEmails = []
166166
} else if (authType === 'password') {
167167
updateData.allowedEmails = []
168-
} else if (authType === 'email') {
168+
} else if (authType === 'email' || authType === 'sso') {
169169
updateData.password = null
170170
}
171171
}

apps/sim/app/api/chat/route.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const chatSchema = z.object({
2727
welcomeMessage: z.string(),
2828
imageUrl: z.string().optional(),
2929
}),
30-
authType: z.enum(['public', 'password', 'email']).default('public'),
30+
authType: z.enum(['public', 'password', 'email', 'sso']).default('public'),
3131
password: z.string().optional(),
3232
allowedEmails: z.array(z.string()).optional().default([]),
3333
outputConfigs: z
@@ -98,6 +98,13 @@ export async function POST(request: NextRequest) {
9898
)
9999
}
100100

101+
if (authType === 'sso' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) {
102+
return createErrorResponse(
103+
'At least one email or domain is required when using SSO access control',
104+
400
105+
)
106+
}
107+
101108
// Check if identifier is available
102109
const existingIdentifier = await db
103110
.select()
@@ -163,7 +170,7 @@ export async function POST(request: NextRequest) {
163170
isActive: true,
164171
authType,
165172
password: encryptedPassword,
166-
allowedEmails: authType === 'email' ? allowedEmails : [],
173+
allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [],
167174
outputConfigs,
168175
createdAt: new Date(),
169176
updatedAt: new Date(),

apps/sim/app/api/chat/utils.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,67 @@ export async function validateChatAuth(
262262
}
263263
}
264264

265-
// Unknown auth type
265+
if (authType === 'sso') {
266+
if (request.method === 'GET') {
267+
return { authorized: false, error: 'auth_required_sso' }
268+
}
269+
270+
try {
271+
if (!parsedBody) {
272+
return { authorized: false, error: 'SSO authentication is required' }
273+
}
274+
275+
const { email, input, checkSSOAccess } = parsedBody
276+
277+
if (checkSSOAccess) {
278+
if (!email) {
279+
return { authorized: false, error: 'Email is required' }
280+
}
281+
282+
const allowedEmails = deployment.allowedEmails || []
283+
284+
if (allowedEmails.includes(email)) {
285+
return { authorized: true }
286+
}
287+
288+
const domain = email.split('@')[1]
289+
if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) {
290+
return { authorized: true }
291+
}
292+
293+
return { authorized: false, error: 'Email not authorized for SSO access' }
294+
}
295+
296+
const { auth } = await import('@/lib/auth')
297+
const session = await auth.api.getSession({ headers: request.headers })
298+
299+
if (!session || !session.user) {
300+
return { authorized: false, error: 'auth_required_sso' }
301+
}
302+
303+
const userEmail = session.user.email
304+
if (!userEmail) {
305+
return { authorized: false, error: 'SSO session does not contain email' }
306+
}
307+
308+
const allowedEmails = deployment.allowedEmails || []
309+
310+
if (allowedEmails.includes(userEmail)) {
311+
return { authorized: true }
312+
}
313+
314+
const domain = userEmail.split('@')[1]
315+
if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) {
316+
return { authorized: true }
317+
}
318+
319+
return { authorized: false, error: 'Your email is not authorized to access this chat' }
320+
} catch (error) {
321+
logger.error(`[${requestId}] Error validating SSO:`, error)
322+
return { authorized: false, error: 'SSO authentication error' }
323+
}
324+
}
325+
266326
return { authorized: false, error: 'Unsupported authentication type' }
267327
}
268328

apps/sim/app/chat/[identifier]/chat.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
ChatMessageContainer,
1515
EmailAuth,
1616
PasswordAuth,
17+
SSOAuth,
1718
VoiceInterface,
1819
} from '@/app/chat/components'
1920
import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/chat/constants'
@@ -32,7 +33,7 @@ interface ChatConfig {
3233
welcomeMessage?: string
3334
headerText?: string
3435
}
35-
authType?: 'public' | 'password' | 'email'
36+
authType?: 'public' | 'password' | 'email' | 'sso'
3637
outputConfigs?: Array<{ blockId: string; path?: string }>
3738
}
3839

@@ -119,7 +120,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
119120
const [userHasScrolled, setUserHasScrolled] = useState(false)
120121
const isUserScrollingRef = useRef(false)
121122

122-
const [authRequired, setAuthRequired] = useState<'password' | 'email' | null>(null)
123+
const [authRequired, setAuthRequired] = useState<'password' | 'email' | 'sso' | null>(null)
123124

124125
const [isVoiceFirstMode, setIsVoiceFirstMode] = useState(false)
125126
const { isStreamingResponse, abortControllerRef, stopStreaming, handleStreamedResponse } =
@@ -222,6 +223,10 @@ export default function ChatClient({ identifier }: { identifier: string }) {
222223
setAuthRequired('email')
223224
return
224225
}
226+
if (errorData.error === 'auth_required_sso') {
227+
setAuthRequired('sso')
228+
return
229+
}
225230
}
226231

227232
throw new Error(`Failed to load chat configuration: ${response.status}`)
@@ -500,6 +505,16 @@ export default function ChatClient({ identifier }: { identifier: string }) {
500505
/>
501506
)
502507
}
508+
if (authRequired === 'sso') {
509+
return (
510+
<SSOAuth
511+
identifier={identifier}
512+
onAuthSuccess={handleAuthSuccess}
513+
title={title}
514+
primaryColor={primaryColor}
515+
/>
516+
)
517+
}
503518
}
504519

505520
// Loading state while fetching config using the extracted component
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
'use client'
2+
3+
import { type KeyboardEvent, useEffect, useState } from 'react'
4+
import { useRouter } from 'next/navigation'
5+
import { Button } from '@/components/ui/button'
6+
import { Input } from '@/components/ui/input'
7+
import { Label } from '@/components/ui/label'
8+
import { quickValidateEmail } from '@/lib/email/validation'
9+
import { createLogger } from '@/lib/logs/console/logger'
10+
import { cn } from '@/lib/utils'
11+
import Nav from '@/app/(landing)/components/nav/nav'
12+
import { inter } from '@/app/fonts/inter'
13+
import { soehne } from '@/app/fonts/soehne/soehne'
14+
15+
const logger = createLogger('SSOAuth')
16+
17+
interface SSOAuthProps {
18+
identifier: string
19+
onAuthSuccess: () => void
20+
title?: string
21+
primaryColor?: string
22+
}
23+
24+
const validateEmailField = (emailValue: string): string[] => {
25+
const errors: string[] = []
26+
27+
if (!emailValue || !emailValue.trim()) {
28+
errors.push('Email is required.')
29+
return errors
30+
}
31+
32+
const validation = quickValidateEmail(emailValue.trim().toLowerCase())
33+
if (!validation.isValid) {
34+
errors.push(validation.reason || 'Please enter a valid email address.')
35+
}
36+
37+
return errors
38+
}
39+
40+
export default function SSOAuth({
41+
identifier,
42+
onAuthSuccess,
43+
title = 'chat',
44+
primaryColor = 'var(--brand-primary-hover-hex)',
45+
}: SSOAuthProps) {
46+
const router = useRouter()
47+
const [email, setEmail] = useState('')
48+
const [emailErrors, setEmailErrors] = useState<string[]>([])
49+
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
50+
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
51+
const [isLoading, setIsLoading] = useState(false)
52+
53+
useEffect(() => {
54+
const checkCustomBrand = () => {
55+
const computedStyle = getComputedStyle(document.documentElement)
56+
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
57+
58+
if (brandAccent && brandAccent !== '#6f3dfa') {
59+
setButtonClass('auth-button-custom')
60+
} else {
61+
setButtonClass('auth-button-gradient')
62+
}
63+
}
64+
65+
checkCustomBrand()
66+
67+
window.addEventListener('resize', checkCustomBrand)
68+
const observer = new MutationObserver(checkCustomBrand)
69+
observer.observe(document.documentElement, {
70+
attributes: true,
71+
attributeFilter: ['style', 'class'],
72+
})
73+
74+
return () => {
75+
window.removeEventListener('resize', checkCustomBrand)
76+
observer.disconnect()
77+
}
78+
}, [])
79+
80+
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
81+
if (e.key === 'Enter') {
82+
e.preventDefault()
83+
handleAuthenticate()
84+
}
85+
}
86+
87+
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
88+
const newEmail = e.target.value
89+
setEmail(newEmail)
90+
setShowEmailValidationError(false)
91+
setEmailErrors([])
92+
}
93+
94+
const handleAuthenticate = async () => {
95+
const emailValidationErrors = validateEmailField(email)
96+
setEmailErrors(emailValidationErrors)
97+
setShowEmailValidationError(emailValidationErrors.length > 0)
98+
99+
if (emailValidationErrors.length > 0) {
100+
return
101+
}
102+
103+
setIsLoading(true)
104+
105+
try {
106+
const checkResponse = await fetch(`/api/chat/${identifier}`, {
107+
method: 'POST',
108+
credentials: 'same-origin',
109+
headers: {
110+
'Content-Type': 'application/json',
111+
'X-Requested-With': 'XMLHttpRequest',
112+
},
113+
body: JSON.stringify({ email, checkSSOAccess: true }),
114+
})
115+
116+
if (!checkResponse.ok) {
117+
const errorData = await checkResponse.json()
118+
setEmailErrors([errorData.error || 'Email not authorized for this chat'])
119+
setShowEmailValidationError(true)
120+
setIsLoading(false)
121+
return
122+
}
123+
124+
const callbackUrl = `/chat/${identifier}`
125+
const ssoUrl = `/sso?email=${encodeURIComponent(email)}&callbackUrl=${encodeURIComponent(callbackUrl)}`
126+
router.push(ssoUrl)
127+
} catch (error) {
128+
logger.error('SSO authentication error:', error)
129+
setEmailErrors(['An error occurred during authentication'])
130+
setShowEmailValidationError(true)
131+
setIsLoading(false)
132+
}
133+
}
134+
135+
return (
136+
<div className='bg-white'>
137+
<Nav variant='auth' />
138+
<div className='flex min-h-[calc(100vh-120px)] items-center justify-center px-4'>
139+
<div className='w-full max-w-[410px]'>
140+
<div className='flex flex-col items-center justify-center'>
141+
{/* Header */}
142+
<div className='space-y-1 text-center'>
143+
<h1
144+
className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}
145+
>
146+
SSO Authentication
147+
</h1>
148+
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
149+
This chat requires SSO authentication
150+
</p>
151+
</div>
152+
153+
{/* Form */}
154+
<form
155+
onSubmit={(e) => {
156+
e.preventDefault()
157+
handleAuthenticate()
158+
}}
159+
className={`${inter.className} mt-8 w-full space-y-8`}
160+
>
161+
<div className='space-y-6'>
162+
<div className='space-y-2'>
163+
<div className='flex items-center justify-between'>
164+
<Label htmlFor='email'>Work Email</Label>
165+
</div>
166+
<Input
167+
id='email'
168+
name='email'
169+
required
170+
type='email'
171+
autoCapitalize='none'
172+
autoComplete='email'
173+
autoCorrect='off'
174+
placeholder='Enter your work email'
175+
value={email}
176+
onChange={handleEmailChange}
177+
onKeyDown={handleKeyDown}
178+
className={cn(
179+
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
180+
showEmailValidationError &&
181+
emailErrors.length > 0 &&
182+
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
183+
)}
184+
autoFocus
185+
/>
186+
{showEmailValidationError && emailErrors.length > 0 && (
187+
<div className='mt-1 space-y-1 text-red-400 text-xs'>
188+
{emailErrors.map((error, index) => (
189+
<p key={index}>{error}</p>
190+
))}
191+
</div>
192+
)}
193+
</div>
194+
</div>
195+
196+
<Button
197+
type='submit'
198+
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
199+
disabled={isLoading}
200+
>
201+
{isLoading ? 'Redirecting to SSO...' : 'Continue with SSO'}
202+
</Button>
203+
</form>
204+
</div>
205+
</div>
206+
</div>
207+
</div>
208+
)
209+
}

0 commit comments

Comments
 (0)