diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 24e6b709997..a6dff447355 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -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 } @@ -358,6 +360,7 @@ export const GET = withRouteHandler( if ( deployment.authType !== 'public' && + deployment.authType !== 'sso' && authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password) ) { diff --git a/apps/sim/app/api/chat/[identifier]/sso/route.ts b/apps/sim/app/api/chat/[identifier]/sso/route.ts new file mode 100644 index 00000000000..812f27df5b3 --- /dev/null +++ b/apps/sim/app/api/chat/[identifier]/sso/route.ts @@ -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) + } +) diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 31401d6b5ec..60395c0bbd1 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -19,6 +19,7 @@ const { mockSetDeploymentAuthCookie, mockAddCorsHeaders, mockIsEmailAllowed, + mockGetSession, } = vi.hoisted(() => ({ mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}), mockMergeSubBlockValues: vi.fn().mockReturnValue({}), @@ -26,6 +27,12 @@ const { 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 @@ -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', () => { diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 3909dd599fe..5a3d0750e8d 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -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 } + } } if (authType === 'password') { @@ -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() diff --git a/apps/sim/ee/sso/components/sso-auth.tsx b/apps/sim/ee/sso/components/sso-auth.tsx index 64affa51887..1dd99581741 100644 --- a/apps/sim/ee/sso/components/sso-auth.tsx +++ b/apps/sim/ee/sso/components/sso-auth.tsx @@ -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' @@ -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) diff --git a/apps/sim/lib/api/contracts/chats.ts b/apps/sim/lib/api/contracts/chats.ts index 3d3558adccf..c3e908121b6 100644 --- a/apps/sim/lib/api/contracts/chats.ts +++ b/apps/sim/lib/api/contracts/chats.ts @@ -104,13 +104,10 @@ export const deployedChatConfigSchema = z.object({ }) export type DeployedChatConfig = z.output -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 export const deployedChatFileSchema = z.object({ @@ -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 +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 + export const chatEmailOtpRequestBodySchema = z.object({ email: z.string().email('Invalid email address'), }) @@ -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', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 751c1919f54..909e680e710 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -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