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
44 changes: 34 additions & 10 deletions apps/sim/app/api/chat/[identifier]/otp/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const {
mockSendEmail,
mockRenderOTPEmail,
mockSetChatAuthCookie,
mockIsEmailAllowed,
mockGetStorageMethod,
mockZodParse,
mockAfterResponse,
Expand All @@ -48,6 +49,11 @@ const {
const mockSendEmail = vi.fn()
const mockRenderOTPEmail = vi.fn()
const mockSetChatAuthCookie = vi.fn()
const mockIsEmailAllowed = vi.fn((email: string, allowedEmails: string[]) => {
if (allowedEmails.includes(email)) return true
const domain = email.slice(email.indexOf('@') + 1)
return allowedEmails.includes(`@${domain}`)
})
const mockGetStorageMethod = vi.fn()
const mockZodParse = vi.fn()
const mockAfterResponse = vi.fn()
Expand All @@ -62,6 +68,7 @@ const {
mockSendEmail,
mockRenderOTPEmail,
mockSetChatAuthCookie,
mockIsEmailAllowed,
mockGetStorageMethod,
mockZodParse,
mockAfterResponse,
Expand Down Expand Up @@ -101,15 +108,7 @@ vi.mock('@/components/emails', () => ({
}))

vi.mock('@/lib/core/security/deployment', () => ({
isEmailAllowed: (email: string, allowedEmails: string[]) => {
if (allowedEmails.includes(email)) return true
const atIndex = email.indexOf('@')
if (atIndex > 0) {
const domain = email.substring(atIndex + 1)
if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) return true
}
return false
},
isEmailAllowed: mockIsEmailAllowed,
}))

vi.mock('@/app/api/chat/utils', () => ({
Expand Down Expand Up @@ -173,7 +172,7 @@ describe('Chat OTP API Route', () => {

/** Queues the chat-deployment row the route reads before touching OTP storage. */
const queueDeployment = (row: Record<string, unknown>) => {
queueTableRows(schemaMock.chat, [row])
queueTableRows(schemaMock.chat, [{ allowedEmails: [mockEmail], ...row }])
}

const emailDeployment = {
Expand Down Expand Up @@ -483,6 +482,15 @@ describe('Chat OTP API Route', () => {

expect(mockRedisGet).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`)
expect(mockRedisDel).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`)
expect(mockSetChatAuthCookie).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
id: mockChatId,
authType: 'email',
allowedEmails: [mockEmail],
}),
mockEmail
)
expect(dbChainMockFns.select).toHaveBeenCalledTimes(1)
})
})
Expand Down Expand Up @@ -514,6 +522,22 @@ describe('Chat OTP API Route', () => {
expect(mockRedisGet).not.toHaveBeenCalled()
expect(mockSetChatAuthCookie).not.toHaveBeenCalled()
})

it('rejects verification when the email is no longer allowed', async () => {
mockIsEmailAllowed.mockReturnValueOnce(false)
queueDeployment({ id: mockChatId, authType: 'email' })

const request = new NextRequest('http://localhost:3000/api/chat/test/otp', {
method: 'PUT',
body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
})

await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })

expect(mockCreateErrorResponse).toHaveBeenCalledWith('Email not authorized', 403)
expect(mockRedisGet).not.toHaveBeenCalled()
expect(mockSetChatAuthCookie).not.toHaveBeenCalled()
})
})

describe('PUT - Verify OTP (Database path)', () => {
Expand Down
12 changes: 9 additions & 3 deletions apps/sim/app/api/chat/[identifier]/otp/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { db } from '@sim/db'
import { chat } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { normalizeEmail } from '@sim/utils/string'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { getOtpSubject, renderOTPEmail } from '@/components/emails'
Expand Down Expand Up @@ -103,7 +104,7 @@ export const POST = withRouteHandler(
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
})
if (!parsed.success) return parsed.response
const { email } = parsed.data.body
const email = normalizeEmail(parsed.data.body.email)

const deploymentResult = await db
.select({
Expand Down Expand Up @@ -157,7 +158,8 @@ export const PUT = withRouteHandler(
createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
})
if (!parsed.success) return parsed.response
const { email, otp } = parsed.data.body
const { otp } = parsed.data.body
const email = normalizeEmail(parsed.data.body.email)

const deploymentResult = await db
.select({
Expand All @@ -167,6 +169,7 @@ export const PUT = withRouteHandler(
customizations: chat.customizations,
authType: chat.authType,
password: chat.password,
allowedEmails: chat.allowedEmails,
outputConfigs: chat.outputConfigs,
includeThinking: chat.includeThinking,
includeToolCalls: chat.includeToolCalls,
Expand All @@ -187,6 +190,9 @@ export const PUT = withRouteHandler(
if (deployment.authType !== 'email') {
return createErrorResponse('This chat does not use email authentication', 400)
}
if (!isEmailAllowed(email, deployment.allowedEmails)) {
return createErrorResponse('Email not authorized', 403)
}

const storedValue = await getOTP('chat', deployment.id, email)
if (!storedValue) {
Expand Down Expand Up @@ -222,7 +228,7 @@ export const PUT = withRouteHandler(
includeThinking: deployment.includeThinking ?? false,
includeToolCalls: deployment.includeToolCalls ?? false,
})
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
setChatAuthCookie(response, deployment, email)

return response
} catch (error) {
Expand Down
33 changes: 18 additions & 15 deletions apps/sim/app/api/chat/[identifier]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,11 @@ const createMockStream = () => {
})
}

const { mockValidateChatAuth, mockSetChatAuthCookie, mockValidateAuthToken, mockProcessChatFiles } =
vi.hoisted(() => ({
mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }),
mockSetChatAuthCookie: vi.fn(),
mockValidateAuthToken: vi.fn().mockReturnValue(false),
mockProcessChatFiles: vi.fn(),
}))
const { mockValidateChatAuth, mockSetChatAuthCookie, mockProcessChatFiles } = vi.hoisted(() => ({
mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }),
mockSetChatAuthCookie: vi.fn(),
mockProcessChatFiles: vi.fn(),
}))

const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
Expand All @@ -82,12 +80,6 @@ vi.mock('@sim/db', () => ({
workflow: {},
}))

vi.mock('@/lib/core/security/deployment', () => ({
validateAuthToken: mockValidateAuthToken,
setDeploymentAuthCookie: vi.fn(),
isEmailAllowed: vi.fn().mockReturnValue(false),
}))

vi.mock('@/app/api/chat/utils', () => ({
validateChatAuth: mockValidateChatAuth,
setChatAuthCookie: mockSetChatAuthCookie,
Expand Down Expand Up @@ -190,7 +182,6 @@ describe('Chat Identifier API Route', () => {
})

mockValidateChatAuth.mockResolvedValue({ authorized: true })
mockValidateAuthToken.mockReturnValue(false)
mockProcessChatFiles.mockResolvedValue([])
mockCreateErrorResponse.mockImplementation((message: string, status: number, code?: string) => {
return new Response(
Expand Down Expand Up @@ -316,6 +307,18 @@ describe('Chat Identifier API Route', () => {

describe('POST endpoint', () => {
it('should return chat config on successful authentication', async () => {
const passwordDeployment = {
...mockChatResult[0],
authType: 'password',
password: 'encrypted-password',
}
dbChainMockFns.select.mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockReturnValue([passwordDeployment]),
}),
}),
}))
const req = createMockNextRequest('POST', { password: 'test-password' })
const params = Promise.resolve({ identifier: 'password-protected-chat' })

Expand All @@ -329,7 +332,7 @@ describe('Chat Identifier API Route', () => {
expect(data).toHaveProperty('customizations')
expect(data.customizations).toHaveProperty('welcomeMessage', 'Welcome to the test chat')

expect(mockSetChatAuthCookie).toHaveBeenCalled()
expect(mockSetChatAuthCookie).toHaveBeenCalledWith(expect.anything(), passwordDeployment)
})

it('should return 400 for requests without input', async () => {
Expand Down
17 changes: 2 additions & 15 deletions apps/sim/app/api/chat/[identifier]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { parseRequest } from '@/lib/api/server'
import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
import { env } from '@/lib/core/config/env'
import { validateAuthToken } from '@/lib/core/security/deployment'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { preprocessExecution } from '@/lib/execution/preprocessing'
Expand Down Expand Up @@ -158,8 +157,8 @@ export const POST = withRouteHandler(
if ((password || email) && !input) {
const response = createSuccessResponse(toChatConfigResponse(deployment))

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

return response
Expand Down Expand Up @@ -394,18 +393,6 @@ export const GET = withRouteHandler(
return createErrorResponse('This chat is currently unavailable', 403)
}

const cookieName = `chat_auth_${deployment.id}`
const authCookie = request.cookies.get(cookieName)

if (
deployment.authType !== 'public' &&
deployment.authType !== 'sso' &&
authCookie &&
validateAuthToken(authCookie.value, deployment.id, deployment.authType, deployment.password)
) {
return createSuccessResponse(toChatConfigResponse(deployment))
}

const authResult = await validateChatAuth(requestId, deployment, request)
if (!authResult.authorized) {
logger.info(
Expand Down
30 changes: 16 additions & 14 deletions apps/sim/app/api/chat/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,10 @@ describe('Chat API Utils', () => {
} as any

const result = await validateChatAuth('request-id', deployment, mockRequest)
expect(mockValidateAuthToken).toHaveBeenCalledWith(
'valid-token',
'chat-id',
'password',
'encrypted-password'
)
expect(mockValidateAuthToken).toHaveBeenCalledWith({
token: 'valid-token',
resource: deployment,
})
expect(result.authorized).toBe(true)
})

Expand Down Expand Up @@ -136,15 +134,19 @@ describe('Chat API Utils', () => {
cookies: { set: vi.fn() },
} as unknown as NextResponse

setChatAuthCookie(mockResponse, 'test-chat-id', 'password')
const deployment = {
id: 'test-chat-id',
authType: 'password',
password: 'encrypted-password',
}
setChatAuthCookie(mockResponse, deployment)

expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith(
mockResponse,
'chat',
'test-chat-id',
'password',
undefined
)
expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith({
response: mockResponse,
cookiePrefix: 'chat',
resource: deployment,
verifiedEmail: undefined,
})
})
})

Expand Down
22 changes: 15 additions & 7 deletions apps/sim/app/api/chat/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,27 @@ import { chat, workflow } from '@sim/db/schema'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest, NextResponse } from 'next/server'
import { setDeploymentAuthCookie } from '@/lib/core/security/deployment'
import {
type DeploymentAuthResource,
setDeploymentAuthCookie,
} from '@/lib/core/security/deployment'
import {
type DeploymentAuthBody,
type DeploymentAuthResult,
validateDeploymentAuth,
} from '@/lib/core/security/deployment-auth'

export function setChatAuthCookie(
response: NextResponse,
chatId: string,
type: string,
encryptedPassword?: string | null
deployment: DeploymentAuthResource,
verifiedEmail?: string
): void {
setDeploymentAuthCookie(response, 'chat', chatId, type, encryptedPassword)
setDeploymentAuthCookie({
response,
cookiePrefix: 'chat',
resource: deployment,
verifiedEmail,
})
}

/**
Expand Down Expand Up @@ -85,9 +93,9 @@ export async function checkChatAccess(
*/
export async function validateChatAuth(
requestId: string,
deployment: any,
deployment: DeploymentAuthResource,
request: NextRequest,
parsedBody?: any
parsedBody?: DeploymentAuthBody
): Promise<DeploymentAuthResult> {
return validateDeploymentAuth(requestId, deployment, request, parsedBody, 'chat')
}
25 changes: 18 additions & 7 deletions apps/sim/app/api/files/public/[token]/otp/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ describe('PUT /api/files/public/[token]/otp', () => {
beforeEach(() => {
vi.clearAllMocks()
mockResolveActiveShareByToken.mockResolvedValue(emailShare)
mockIsEmailAllowed.mockReturnValue(true)
mockGetOTP.mockResolvedValue('123456:0')
mockDecodeOTPValue.mockReturnValue({ otp: '123456', attempts: 0 })
})
Expand All @@ -232,13 +233,23 @@ describe('PUT /api/files/public/[token]/otp', () => {
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ authType: 'email' })
expect(mockDeleteOTP).toHaveBeenCalledWith('file', 'sh_1', 'user@acme.com')
expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith(
expect.anything(),
'file',
'sh_1',
'email',
null
)
expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith({
response: expect.anything(),
cookiePrefix: 'file',
resource: emailShare.share,
verifiedEmail: 'user@acme.com',
})
})

it('rejects a valid code when the email is no longer allowed', async () => {
mockIsEmailAllowed.mockReturnValueOnce(false)

const res = await PUT(put('user@acme.com', '123456'), params())

expect(res.status).toBe(403)
expect(mockGetOTP).not.toHaveBeenCalled()
expect(mockDeleteOTP).not.toHaveBeenCalled()
expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled()
})

it('rejects a wrong code with 400 and increments attempts', async () => {
Expand Down
Loading
Loading