Skip to content

Commit 66bab93

Browse files
authored
fix(chat): close SSO auth bypass via checkSSOAccess body flag (#4408)
* 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 * fix(chat): cast allowedEmails to string[] for SSO eligibility check * 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
1 parent 1a76a22 commit 66bab93

7 files changed

Lines changed: 197 additions & 43 deletions

File tree

apps/sim/app/api/chat/[identifier]/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,9 @@ export const POST = withRouteHandler(
149149
request
150150
)
151151

152-
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
152+
if (deployment.authType !== 'sso') {
153+
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
154+
}
153155

154156
return response
155157
}
@@ -358,6 +360,7 @@ export const GET = withRouteHandler(
358360

359361
if (
360362
deployment.authType !== 'public' &&
363+
deployment.authType !== 'sso' &&
361364
authCookie &&
362365
validateAuthToken(authCookie.value, deployment.id, deployment.password)
363366
) {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { db } from '@sim/db'
2+
import { chat } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, eq, isNull } from 'drizzle-orm'
5+
import type { NextRequest } from 'next/server'
6+
import { chatSSOContract } from '@/lib/api/contracts/chats'
7+
import { parseRequest } from '@/lib/api/server'
8+
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
9+
import { RateLimiter } from '@/lib/core/rate-limiter'
10+
import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment'
11+
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
12+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
13+
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
14+
15+
const logger = createLogger('ChatSSOAPI')
16+
17+
export const dynamic = 'force-dynamic'
18+
export const runtime = 'nodejs'
19+
20+
const rateLimiter = new RateLimiter()
21+
22+
const SSO_IP_RATE_LIMIT: TokenBucketConfig = {
23+
maxTokens: 20,
24+
refillRate: 20,
25+
refillIntervalMs: 15 * 60_000,
26+
}
27+
28+
export const POST = withRouteHandler(
29+
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
30+
const requestId = generateRequestId()
31+
32+
const ip = getClientIp(request)
33+
if (ip !== 'unknown') {
34+
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
35+
`chat-sso:ip:${ip}`,
36+
SSO_IP_RATE_LIMIT
37+
)
38+
if (!ipRateLimit.allowed) {
39+
logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`)
40+
const retryAfter = Math.ceil(
41+
(ipRateLimit.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs) / 1000
42+
)
43+
const response = createErrorResponse('Too many requests. Please try again later.', 429)
44+
response.headers.set('Retry-After', String(retryAfter))
45+
return addCorsHeaders(response, request)
46+
}
47+
}
48+
49+
const parsed = await parseRequest(chatSSOContract, request, context)
50+
if (!parsed.success) return parsed.response
51+
52+
const { identifier } = parsed.data.params
53+
const { email } = parsed.data.body
54+
55+
const [deployment] = await db
56+
.select({
57+
authType: chat.authType,
58+
allowedEmails: chat.allowedEmails,
59+
isActive: chat.isActive,
60+
})
61+
.from(chat)
62+
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
63+
.limit(1)
64+
65+
if (!deployment || !deployment.isActive) {
66+
logger.warn(`[${requestId}] SSO check on missing/inactive chat: ${identifier}`)
67+
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
68+
}
69+
70+
if (deployment.authType !== 'sso') {
71+
return addCorsHeaders(
72+
createErrorResponse('Chat is not configured for SSO authentication', 400),
73+
request
74+
)
75+
}
76+
77+
const eligible = isEmailAllowed(email, (deployment.allowedEmails as string[]) || [])
78+
79+
return addCorsHeaders(createSuccessResponse({ eligible }), request)
80+
}
81+
)

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,20 @@ const {
1919
mockSetDeploymentAuthCookie,
2020
mockAddCorsHeaders,
2121
mockIsEmailAllowed,
22+
mockGetSession,
2223
} = vi.hoisted(() => ({
2324
mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
2425
mockMergeSubBlockValues: vi.fn().mockReturnValue({}),
2526
mockValidateAuthToken: vi.fn().mockReturnValue(false),
2627
mockSetDeploymentAuthCookie: vi.fn(),
2728
mockAddCorsHeaders: vi.fn((response: unknown) => response),
2829
mockIsEmailAllowed: vi.fn(),
30+
mockGetSession: vi.fn(),
31+
}))
32+
33+
vi.mock('@/lib/auth', () => ({
34+
auth: { api: { getSession: vi.fn() } },
35+
getSession: mockGetSession,
2936
}))
3037

3138
const mockDecryptSecret = encryptionMockFns.mockDecryptSecret
@@ -285,6 +292,68 @@ describe('Chat API Utils', () => {
285292
expect(result3.authorized).toBe(false)
286293
expect(result3.error).toBe('Email not authorized')
287294
})
295+
296+
describe('SSO auth', () => {
297+
const ssoDeployment = {
298+
id: 'chat-id',
299+
authType: 'sso',
300+
allowedEmails: ['user@example.com', '@company.com'],
301+
}
302+
303+
const postRequest = {
304+
method: 'POST',
305+
cookies: { get: vi.fn().mockReturnValue(null) },
306+
} as any
307+
308+
it('rejects when no session is present', async () => {
309+
mockGetSession.mockResolvedValue(null)
310+
311+
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
312+
input: 'hello',
313+
})
314+
315+
expect(result.authorized).toBe(false)
316+
expect(result.error).toBe('auth_required_sso')
317+
})
318+
319+
it('ignores body-supplied email and uses the session email', async () => {
320+
mockGetSession.mockResolvedValue({ user: { email: 'session@example.com' } })
321+
mockIsEmailAllowed.mockReturnValue(true)
322+
323+
await validateChatAuth('request-id', ssoDeployment, postRequest, {
324+
email: 'attacker@evil.com',
325+
input: 'hello',
326+
})
327+
328+
expect(mockIsEmailAllowed).toHaveBeenCalledWith(
329+
'session@example.com',
330+
ssoDeployment.allowedEmails
331+
)
332+
})
333+
334+
it('authorizes execution when session email is allowlisted', async () => {
335+
mockGetSession.mockResolvedValue({ user: { email: 'user@example.com' } })
336+
mockIsEmailAllowed.mockReturnValue(true)
337+
338+
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
339+
input: 'hello',
340+
})
341+
342+
expect(result.authorized).toBe(true)
343+
})
344+
345+
it('rejects execution when session email is not allowlisted', async () => {
346+
mockGetSession.mockResolvedValue({ user: { email: 'stranger@other.com' } })
347+
mockIsEmailAllowed.mockReturnValue(false)
348+
349+
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
350+
input: 'hello',
351+
})
352+
353+
expect(result.authorized).toBe(false)
354+
expect(result.error).toBe('Your email is not authorized to access this chat')
355+
})
356+
})
288357
})
289358

290359
describe('Execution Result Processing', () => {

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

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,13 @@ export async function validateChatAuth(
9595
return { authorized: true }
9696
}
9797

98-
const cookieName = `chat_auth_${deployment.id}`
99-
const authCookie = request.cookies.get(cookieName)
98+
if (authType !== 'sso') {
99+
const cookieName = `chat_auth_${deployment.id}`
100+
const authCookie = request.cookies.get(cookieName)
100101

101-
if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) {
102-
return { authorized: true }
102+
if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) {
103+
return { authorized: true }
104+
}
103105
}
104106

105107
if (authType === 'password') {
@@ -173,35 +175,11 @@ export async function validateChatAuth(
173175
}
174176

175177
if (authType === 'sso') {
176-
if (request.method === 'GET') {
177-
return { authorized: false, error: 'auth_required_sso' }
178-
}
179-
180178
try {
181-
if (!parsedBody) {
179+
if (request.method !== 'GET' && !parsedBody) {
182180
return { authorized: false, error: 'SSO authentication is required' }
183181
}
184182

185-
const { email, input, checkSSOAccess } = parsedBody
186-
187-
if (input && !checkSSOAccess) {
188-
return { authorized: false, error: 'auth_required_sso' }
189-
}
190-
191-
if (checkSSOAccess) {
192-
if (!email) {
193-
return { authorized: false, error: 'Email is required' }
194-
}
195-
196-
const allowedEmails = deployment.allowedEmails || []
197-
198-
if (isEmailAllowed(email, allowedEmails)) {
199-
return { authorized: true }
200-
}
201-
202-
return { authorized: false, error: 'Email not authorized for SSO access' }
203-
}
204-
205183
const { getSession } = await import('@/lib/auth')
206184
const session = await getSession()
207185

apps/sim/ee/sso/components/sso-auth.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation'
66
import { Input, Label, Loader } from '@/components/emcn'
77
import { ApiClientError } from '@/lib/api/client/errors'
88
import { requestJson } from '@/lib/api/client/request'
9-
import { authenticateDeployedChatContract } from '@/lib/api/contracts/chats'
9+
import { chatSSOContract } from '@/lib/api/contracts/chats'
1010
import { cn } from '@/lib/core/utils/cn'
1111
import { quickValidateEmail } from '@/lib/messaging/email/validation'
1212
import AuthBackground from '@/app/(auth)/components/auth-background'
@@ -69,11 +69,18 @@ export default function SSOAuth({ identifier }: SSOAuthProps) {
6969
setIsLoading(true)
7070

7171
try {
72-
await requestJson(authenticateDeployedChatContract, {
72+
const { eligible } = await requestJson(chatSSOContract, {
7373
params: { identifier },
74-
body: { email, checkSSOAccess: true },
74+
body: { email },
7575
})
7676

77+
if (!eligible) {
78+
setEmailErrors(['Email not authorized for this chat'])
79+
setShowEmailValidationError(true)
80+
setIsLoading(false)
81+
return
82+
}
83+
7784
const callbackUrl = `/chat/${identifier}`
7885
const ssoUrl = `/sso?email=${encodeURIComponent(email)}&callbackUrl=${encodeURIComponent(callbackUrl)}`
7986
router.push(ssoUrl)

apps/sim/lib/api/contracts/chats.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,10 @@ export const deployedChatConfigSchema = z.object({
104104
})
105105
export type DeployedChatConfig = z.output<typeof deployedChatConfigSchema>
106106

107-
export const deployedChatAuthBodySchema = z
108-
.object({
109-
password: z.string().optional(),
110-
email: z.string().email('Invalid email format').optional().or(z.literal('')),
111-
checkSSOAccess: z.boolean().optional(),
112-
})
113-
.passthrough()
107+
export const deployedChatAuthBodySchema = z.object({
108+
password: z.string().optional(),
109+
email: z.string().email('Invalid email format').optional().or(z.literal('')),
110+
})
114111
export type DeployedChatAuthBody = z.input<typeof deployedChatAuthBodySchema>
115112

116113
export const deployedChatFileSchema = z.object({
@@ -125,12 +122,20 @@ export const deployedChatPostBodySchema = z.object({
125122
input: z.string().optional(),
126123
password: z.string().optional(),
127124
email: z.string().email('Invalid email format').optional().or(z.literal('')),
128-
checkSSOAccess: z.boolean().optional(),
129125
conversationId: z.string().optional(),
130126
files: z.array(deployedChatFileSchema).optional().default([]),
131127
})
132128
export type DeployedChatPostBody = z.input<typeof deployedChatPostBodySchema>
133129

130+
export const chatSSOBodySchema = z.object({
131+
email: z.string().email('Invalid email address'),
132+
})
133+
134+
export const chatSSOResponseSchema = z.object({
135+
eligible: z.boolean(),
136+
})
137+
export type ChatSSOResponse = z.output<typeof chatSSOResponseSchema>
138+
134139
export const chatEmailOtpRequestBodySchema = z.object({
135140
email: z.string().email('Invalid email address'),
136141
})
@@ -198,6 +203,17 @@ export const deployedChatPostContract = defineRouteContract({
198203
},
199204
})
200205

206+
export const chatSSOContract = defineRouteContract({
207+
method: 'POST',
208+
path: '/api/chat/[identifier]/sso',
209+
params: chatIdentifierParamsSchema,
210+
body: chatSSOBodySchema,
211+
response: {
212+
mode: 'json',
213+
schema: chatSSOResponseSchema,
214+
},
215+
})
216+
201217
export const requestChatEmailOtpContract = defineRouteContract({
202218
method: 'POST',
203219
path: '/api/chat/[identifier]/otp',

scripts/check-api-validation-contracts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
99
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
1010

1111
const BASELINE = {
12-
totalRoutes: 717,
13-
zodRoutes: 717,
12+
totalRoutes: 718,
13+
zodRoutes: 718,
1414
nonZodRoutes: 0,
1515
} as const
1616

0 commit comments

Comments
 (0)