From 7f1098339027a2021a8434aa7f3b0cb92f3bf124 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 2 May 2026 10:52:19 -0700 Subject: [PATCH 1/3] fix(chat): close SSO auth bypass via checkSSOAccess body flag - Remove checkSSOAccess short-circuit; SSO branch always validates via getSession() - Skip chat_auth cookie issuance/validation for SSO deployments to prevent replay - Split eligibility pre-flight into dedicated POST /api/chat/[identifier]/sso route - Drop .passthrough() and checkSSOAccess from deployed chat contracts - Add SSO branch test coverage in chat utils --- apps/sim/app/api/chat/[identifier]/route.ts | 4 +- .../app/api/chat/[identifier]/sso/route.ts | 54 +++++++++++++++ apps/sim/app/api/chat/utils.test.ts | 69 +++++++++++++++++++ apps/sim/app/api/chat/utils.ts | 30 ++------ apps/sim/ee/sso/components/sso-auth.tsx | 13 +++- apps/sim/lib/api/contracts/chats.ts | 32 ++++++--- scripts/check-api-validation-contracts.ts | 4 +- 7 files changed, 168 insertions(+), 38 deletions(-) create mode 100644 apps/sim/app/api/chat/[identifier]/sso/route.ts diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 24e6b709997..6ea33469385 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 } 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..5fa4df010cd --- /dev/null +++ b/apps/sim/app/api/chat/[identifier]/sso/route.ts @@ -0,0 +1,54 @@ +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 { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment' +import { generateRequestId } 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' + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => { + const requestId = generateRequestId() + + 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 || []) + + 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..997a07afb9c 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') { @@ -182,26 +184,6 @@ export async function validateChatAuth( 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 From 7a0326298fce1443768623f53a5323da1e1b64d8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 2 May 2026 11:02:00 -0700 Subject: [PATCH 2/3] fix(chat): cast allowedEmails to string[] for SSO eligibility check --- apps/sim/app/api/chat/[identifier]/sso/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/api/chat/[identifier]/sso/route.ts b/apps/sim/app/api/chat/[identifier]/sso/route.ts index 5fa4df010cd..e8bd0e9b63a 100644 --- a/apps/sim/app/api/chat/[identifier]/sso/route.ts +++ b/apps/sim/app/api/chat/[identifier]/sso/route.ts @@ -47,7 +47,7 @@ export const POST = withRouteHandler( ) } - const eligible = isEmailAllowed(email, deployment.allowedEmails || []) + const eligible = isEmailAllowed(email, (deployment.allowedEmails as string[]) || []) return addCorsHeaders(createSuccessResponse({ eligible }), request) } From 92ee9fe4373d930c7e5e4c33ee292670bb1e1620 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 2 May 2026 11:05:59 -0700 Subject: [PATCH 3/3] fix(chat): close SSO GET cookie replay and add eligibility rate limit - Skip chat_auth cookie validation for SSO in GET handler (replay vector for pre-fix cookies) - Route SSO GET through getSession() instead of always returning auth_required_sso so post-IdP config fetch works - Add per-IP rate limiting to /api/chat/[identifier]/sso to prevent allowlist enumeration --- apps/sim/app/api/chat/[identifier]/route.ts | 1 + .../app/api/chat/[identifier]/sso/route.ts | 29 ++++++++++++++++++- apps/sim/app/api/chat/utils.ts | 6 +--- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 6ea33469385..a6dff447355 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -360,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 index e8bd0e9b63a..812f27df5b3 100644 --- a/apps/sim/app/api/chat/[identifier]/sso/route.ts +++ b/apps/sim/app/api/chat/[identifier]/sso/route.ts @@ -5,8 +5,10 @@ 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 } from '@/lib/core/utils/request' +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' @@ -15,10 +17,35 @@ 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 diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 997a07afb9c..5a3d0750e8d 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -175,12 +175,8 @@ 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' } }