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
5 changes: 4 additions & 1 deletion apps/sim/app/api/chat/[identifier]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ export const POST = withRouteHandler(
request
)

setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
if (deployment.authType !== 'sso') {
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
}

return response
}
Expand Down Expand Up @@ -358,6 +360,7 @@ export const GET = withRouteHandler(

if (
deployment.authType !== 'public' &&
deployment.authType !== 'sso' &&
authCookie &&
validateAuthToken(authCookie.value, deployment.id, deployment.password)
) {
Expand Down
81 changes: 81 additions & 0 deletions apps/sim/app/api/chat/[identifier]/sso/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { chatSSOContract } from '@/lib/api/contracts/chats'
import { parseRequest } from '@/lib/api/server'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment'
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'

const logger = createLogger('ChatSSOAPI')

export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'

const rateLimiter = new RateLimiter()

const SSO_IP_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 20,
refillRate: 20,
refillIntervalMs: 15 * 60_000,
}

export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
const requestId = generateRequestId()

const ip = getClientIp(request)
if (ip !== 'unknown') {
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
`chat-sso:ip:${ip}`,
SSO_IP_RATE_LIMIT
)
if (!ipRateLimit.allowed) {
logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`)
const retryAfter = Math.ceil(
(ipRateLimit.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs) / 1000
)
const response = createErrorResponse('Too many requests. Please try again later.', 429)
response.headers.set('Retry-After', String(retryAfter))
return addCorsHeaders(response, request)
}
}

const parsed = await parseRequest(chatSSOContract, request, context)
if (!parsed.success) return parsed.response

const { identifier } = parsed.data.params
const { email } = parsed.data.body

const [deployment] = await db
.select({
authType: chat.authType,
allowedEmails: chat.allowedEmails,
isActive: chat.isActive,
})
.from(chat)
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
.limit(1)

if (!deployment || !deployment.isActive) {
logger.warn(`[${requestId}] SSO check on missing/inactive chat: ${identifier}`)
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
}

if (deployment.authType !== 'sso') {
return addCorsHeaders(
createErrorResponse('Chat is not configured for SSO authentication', 400),
request
)
}

const eligible = isEmailAllowed(email, (deployment.allowedEmails as string[]) || [])

return addCorsHeaders(createSuccessResponse({ eligible }), request)
}
)
Comment thread
waleedlatif1 marked this conversation as resolved.
69 changes: 69 additions & 0 deletions apps/sim/app/api/chat/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,20 @@ const {
mockSetDeploymentAuthCookie,
mockAddCorsHeaders,
mockIsEmailAllowed,
mockGetSession,
} = vi.hoisted(() => ({
mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
mockMergeSubBlockValues: vi.fn().mockReturnValue({}),
mockValidateAuthToken: vi.fn().mockReturnValue(false),
mockSetDeploymentAuthCookie: vi.fn(),
mockAddCorsHeaders: vi.fn((response: unknown) => response),
mockIsEmailAllowed: vi.fn(),
mockGetSession: vi.fn(),
}))

vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
getSession: mockGetSession,
}))

const mockDecryptSecret = encryptionMockFns.mockDecryptSecret
Expand Down Expand Up @@ -285,6 +292,68 @@ describe('Chat API Utils', () => {
expect(result3.authorized).toBe(false)
expect(result3.error).toBe('Email not authorized')
})

describe('SSO auth', () => {
const ssoDeployment = {
id: 'chat-id',
authType: 'sso',
allowedEmails: ['user@example.com', '@company.com'],
}

const postRequest = {
method: 'POST',
cookies: { get: vi.fn().mockReturnValue(null) },
} as any

it('rejects when no session is present', async () => {
mockGetSession.mockResolvedValue(null)

const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
input: 'hello',
})

expect(result.authorized).toBe(false)
expect(result.error).toBe('auth_required_sso')
})

it('ignores body-supplied email and uses the session email', async () => {
mockGetSession.mockResolvedValue({ user: { email: 'session@example.com' } })
mockIsEmailAllowed.mockReturnValue(true)

await validateChatAuth('request-id', ssoDeployment, postRequest, {
email: 'attacker@evil.com',
input: 'hello',
})

expect(mockIsEmailAllowed).toHaveBeenCalledWith(
'session@example.com',
ssoDeployment.allowedEmails
)
})

it('authorizes execution when session email is allowlisted', async () => {
mockGetSession.mockResolvedValue({ user: { email: 'user@example.com' } })
mockIsEmailAllowed.mockReturnValue(true)

const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
input: 'hello',
})

expect(result.authorized).toBe(true)
})

it('rejects execution when session email is not allowlisted', async () => {
mockGetSession.mockResolvedValue({ user: { email: 'stranger@other.com' } })
mockIsEmailAllowed.mockReturnValue(false)

const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
input: 'hello',
})

expect(result.authorized).toBe(false)
expect(result.error).toBe('Your email is not authorized to access this chat')
})
})
})

describe('Execution Result Processing', () => {
Expand Down
36 changes: 7 additions & 29 deletions apps/sim/app/api/chat/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,13 @@ export async function validateChatAuth(
return { authorized: true }
}

const cookieName = `chat_auth_${deployment.id}`
const authCookie = request.cookies.get(cookieName)
if (authType !== 'sso') {
const cookieName = `chat_auth_${deployment.id}`
const authCookie = request.cookies.get(cookieName)

if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) {
return { authorized: true }
if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) {
return { authorized: true }
}
Comment thread
waleedlatif1 marked this conversation as resolved.
}

if (authType === 'password') {
Expand Down Expand Up @@ -173,35 +175,11 @@ export async function validateChatAuth(
}

if (authType === 'sso') {
if (request.method === 'GET') {
return { authorized: false, error: 'auth_required_sso' }
}

try {
if (!parsedBody) {
if (request.method !== 'GET' && !parsedBody) {
return { authorized: false, error: 'SSO authentication is required' }
}

const { email, input, checkSSOAccess } = parsedBody

if (input && !checkSSOAccess) {
return { authorized: false, error: 'auth_required_sso' }
}

if (checkSSOAccess) {
if (!email) {
return { authorized: false, error: 'Email is required' }
}

const allowedEmails = deployment.allowedEmails || []

if (isEmailAllowed(email, allowedEmails)) {
return { authorized: true }
}

return { authorized: false, error: 'Email not authorized for SSO access' }
}

const { getSession } = await import('@/lib/auth')
const session = await getSession()

Expand Down
13 changes: 10 additions & 3 deletions apps/sim/ee/sso/components/sso-auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation'
import { Input, Label, Loader } from '@/components/emcn'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { authenticateDeployedChatContract } from '@/lib/api/contracts/chats'
import { chatSSOContract } from '@/lib/api/contracts/chats'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import AuthBackground from '@/app/(auth)/components/auth-background'
Expand Down Expand Up @@ -69,11 +69,18 @@ export default function SSOAuth({ identifier }: SSOAuthProps) {
setIsLoading(true)

try {
await requestJson(authenticateDeployedChatContract, {
const { eligible } = await requestJson(chatSSOContract, {
params: { identifier },
body: { email, checkSSOAccess: true },
body: { email },
})

if (!eligible) {
setEmailErrors(['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)
Expand Down
32 changes: 24 additions & 8 deletions apps/sim/lib/api/contracts/chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,10 @@ export const deployedChatConfigSchema = z.object({
})
export type DeployedChatConfig = z.output<typeof deployedChatConfigSchema>

export const deployedChatAuthBodySchema = z
.object({
password: z.string().optional(),
email: z.string().email('Invalid email format').optional().or(z.literal('')),
checkSSOAccess: z.boolean().optional(),
})
.passthrough()
export const deployedChatAuthBodySchema = z.object({
password: z.string().optional(),
email: z.string().email('Invalid email format').optional().or(z.literal('')),
})
export type DeployedChatAuthBody = z.input<typeof deployedChatAuthBodySchema>

export const deployedChatFileSchema = z.object({
Expand All @@ -125,12 +122,20 @@ export const deployedChatPostBodySchema = z.object({
input: z.string().optional(),
password: z.string().optional(),
email: z.string().email('Invalid email format').optional().or(z.literal('')),
checkSSOAccess: z.boolean().optional(),
conversationId: z.string().optional(),
files: z.array(deployedChatFileSchema).optional().default([]),
})
export type DeployedChatPostBody = z.input<typeof deployedChatPostBodySchema>

export const chatSSOBodySchema = z.object({
email: z.string().email('Invalid email address'),
})

export const chatSSOResponseSchema = z.object({
eligible: z.boolean(),
})
export type ChatSSOResponse = z.output<typeof chatSSOResponseSchema>

export const chatEmailOtpRequestBodySchema = z.object({
email: z.string().email('Invalid email address'),
})
Expand Down Expand Up @@ -198,6 +203,17 @@ export const deployedChatPostContract = defineRouteContract({
},
})

export const chatSSOContract = defineRouteContract({
method: 'POST',
path: '/api/chat/[identifier]/sso',
params: chatIdentifierParamsSchema,
body: chatSSOBodySchema,
response: {
mode: 'json',
schema: chatSSOResponseSchema,
},
})

export const requestChatEmailOtpContract = defineRouteContract({
method: 'POST',
path: '/api/chat/[identifier]/otp',
Expand Down
4 changes: 2 additions & 2 deletions scripts/check-api-validation-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')

const BASELINE = {
totalRoutes: 717,
zodRoutes: 717,
totalRoutes: 718,
zodRoutes: 718,
nonZodRoutes: 0,
} as const

Expand Down
Loading