Skip to content
Open
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
7 changes: 7 additions & 0 deletions apps/sim/app/api/chat/[identifier]/otp/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,13 @@ describe('Chat OTP API Route', () => {

expect(mockRedisGet).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`)
expect(mockRedisDel).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`)
expect(mockSetChatAuthCookie).toHaveBeenCalledWith(
expect.anything(),
mockChatId,
'email',
undefined,
mockEmail
)
expect(dbChainMockFns.select).toHaveBeenCalledTimes(1)
})
})
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/chat/[identifier]/otp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ export const PUT = withRouteHandler(
includeThinking: deployment.includeThinking ?? false,
includeToolCalls: deployment.includeToolCalls ?? false,
})
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password, email)

return response
} catch (error) {
Expand Down
33 changes: 33 additions & 0 deletions apps/sim/app/api/chat/[identifier]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,39 @@ describe('Chat Identifier API Route', () => {
)
}, 10000)

it('executes with the email proven by the chat authentication gate', async () => {
mockValidateChatAuth.mockResolvedValueOnce({
authorized: true,
authenticatedEmail: 'person@example.com',
})
const req = createMockNextRequest('POST', { input: 'Hello world' })

const response = await POST(req, {
params: Promise.resolve({ identifier: 'test-chat' }),
})
expect(response.status).toBe(200)

const streamOptions = vi.mocked(createStreamingResponse).mock.calls[0][0]
await streamOptions.executeFn({
onStream: vi.fn(),
onBlockComplete: vi.fn(),
abortSignal: new AbortController().signal,
})

expect(vi.mocked(executeWorkflow).mock.calls[0][4]).toMatchObject({
principal: {
kind: 'system',
serviceId: 'chat',
workspaceId: 'test-workspace-id',
workflowId: 'workflow-id',
subject: {
kind: 'authenticated_email',
email: 'person@example.com',
},
},
})
}, 10000)

/**
* A row predating the column has no tool policy, so it has not opted in.
* Thinking must not drag tool frames along with it.
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/app/api/chat/[identifier]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,14 @@ export const POST = withRouteHandler(
serviceId: 'chat',
workspaceId,
workflowId: deployment.workflowId,
...(authResult.authenticatedEmail
? {
subject: {
kind: 'authenticated_email' as const,
email: authResult.authenticatedEmail,
},
}
: {}),
},
selectedOutputs,
isSecureMode: true,
Expand Down
72 changes: 52 additions & 20 deletions apps/sim/app/api/chat/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockMergeSubblockStateWithValues,
mockMergeSubBlockValues,
mockValidateAuthToken,
mockReadDeploymentAuthToken,
mockSetDeploymentAuthCookie,
mockIsEmailAllowed,
mockCheckRateLimitDirect,
} = vi.hoisted(() => ({
mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
mockMergeSubBlockValues: vi.fn().mockReturnValue({}),
mockValidateAuthToken: vi.fn().mockReturnValue(false),
mockReadDeploymentAuthToken: vi.fn().mockReturnValue(null),
mockSetDeploymentAuthCookie: vi.fn(),
mockIsEmailAllowed: vi.fn(),
mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }),
Expand Down Expand Up @@ -57,7 +57,7 @@ vi.mock('@sim/workflow-persistence/subblocks', () => ({
vi.mock('@/lib/core/security/encryption', () => encryptionMock)

vi.mock('@/lib/core/security/deployment', () => ({
validateAuthToken: mockValidateAuthToken,
readDeploymentAuthToken: mockReadDeploymentAuthToken,
setDeploymentAuthCookie: mockSetDeploymentAuthCookie,
isEmailAllowed: mockIsEmailAllowed,
deploymentAuthCookieName: (prefix: string, id: string) => `${prefix}_auth_${id}`,
Expand All @@ -84,23 +84,20 @@ describe('Chat API Utils', () => {

describe('Auth token utils', () => {
it('should accept valid auth cookie via validateChatAuth', async () => {
mockValidateAuthToken.mockReturnValue(true)
mockReadDeploymentAuthToken.mockReturnValue({})

const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password',
}

const mockRequest = {
method: 'POST',
cookies: {
get: vi.fn().mockReturnValue({ value: 'valid-token' }),
},
} as any
const mockRequest = createMockRequest('POST', undefined, {
cookie: 'chat_auth_chat-id=valid-token',
})

const result = await validateChatAuth('request-id', deployment, mockRequest)
expect(mockValidateAuthToken).toHaveBeenCalledWith(
expect(mockReadDeploymentAuthToken).toHaveBeenCalledWith(
'valid-token',
'chat-id',
'password',
Expand All @@ -110,24 +107,38 @@ describe('Chat API Utils', () => {
})

it('should reject invalid auth cookie via validateChatAuth', async () => {
mockValidateAuthToken.mockReturnValue(false)
mockReadDeploymentAuthToken.mockReturnValue(null)

const deployment = {
id: 'chat-id',
authType: 'password',
password: 'encrypted-password',
}

const mockRequest = {
method: 'GET',
cookies: {
get: vi.fn().mockReturnValue({ value: 'invalid-token' }),
},
} as any
const mockRequest = createMockRequest('GET', undefined, {
cookie: 'chat_auth_chat-id=invalid-token',
})

const result = await validateChatAuth('request-id', deployment, mockRequest)
expect(result.authorized).toBe(false)
})

it('returns the authenticated email carried by a valid email-auth cookie', async () => {
mockReadDeploymentAuthToken.mockReturnValue({ authenticatedEmail: 'person@example.com' })

const deployment = {
id: 'chat-id',
authType: 'email',
}
const mockRequest = createMockRequest('POST', undefined, {
cookie: 'chat_auth_chat-id=valid-token',
})

await expect(validateChatAuth('request-id', deployment, mockRequest)).resolves.toEqual({
authorized: true,
authenticatedEmail: 'person@example.com',
})
})
})

describe('Cookie handling', () => {
Expand All @@ -143,9 +154,27 @@ describe('Chat API Utils', () => {
'chat',
'test-chat-id',
'password',
undefined,
undefined
)
})

it('forwards an authenticated email into the signed deployment cookie', () => {
const mockResponse = {
cookies: { set: vi.fn() },
} as unknown as NextResponse

setChatAuthCookie(mockResponse, 'test-chat-id', 'email', undefined, 'person@example.com')

expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith(
mockResponse,
'chat',
'test-chat-id',
'email',
undefined,
'person@example.com'
)
})
})

describe('Chat auth validation', () => {
Expand Down Expand Up @@ -427,14 +456,17 @@ describe('Chat API Utils', () => {
})

it('authorizes execution when session email is allowlisted', async () => {
mockGetSession.mockResolvedValue({ user: { email: 'user@example.com' } })
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)
expect(result).toEqual({
authorized: true,
authenticatedEmail: 'user@example.com',
})
})

it('rejects execution when session email is not allowlisted', async () => {
Expand Down
5 changes: 3 additions & 2 deletions apps/sim/app/api/chat/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ export function setChatAuthCookie(
response: NextResponse,
chatId: string,
type: string,
encryptedPassword?: string | null
encryptedPassword?: string | null,
authenticatedEmail?: string
): void {
setDeploymentAuthCookie(response, 'chat', chatId, type, encryptedPassword)
setDeploymentAuthCookie(response, 'chat', chatId, type, encryptedPassword, authenticatedEmail)
}

/**
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/files/public/[token]/otp/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,8 @@ describe('PUT /api/files/public/[token]/otp', () => {
'file',
'sh_1',
'email',
null
null,
'user@acme.com'
)
})

Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/files/public/[token]/otp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ export const PUT = withRouteHandler(
'file',
resolved.share.id,
resolved.share.authType,
resolved.share.password
resolved.share.password,
email
)
logger.info(`[${requestId}] OTP verified for share ${resolved.share.id}`)
return response
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/blocks/blocks/start_trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const StartTriggerBlock: BlockConfig = {
mode: 'advanced',
defaultValue: false,
description:
'Expose trusted, server-injected run metadata under <start.metadata>: userEmail, workspaceId, workflowId, executionId, executionType, executionMode, startTime. Fields describe the invoking run — inside a custom block they identify the calling user and workflow.',
'Expose trusted, server-injected run metadata under <start.metadata>: subject, userEmail, workspaceId, workflowId, executionId, executionType, executionMode, startTime. The subject identifies the authenticated Sim user, chat email, or external provider user without exposing credentials.',
},
],
tools: {
Expand Down
32 changes: 31 additions & 1 deletion apps/sim/executor/handlers/workflow/workflow-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,7 @@ describe('WorkflowBlockHandler', () => {
const ctx = {
...mockContext,
userId: 'consumer-1',
principal: { kind: 'session', userId: 'consumer-1', sessionId: 'session-consumer' },
workspaceId: 'workspace-consumer',
executionId: 'exec-1',
} as ExecutionContext
Expand Down Expand Up @@ -725,6 +726,11 @@ describe('WorkflowBlockHandler', () => {
expect(executorOptions).toHaveLength(1)
const startRunMetadata = executorOptions[0].contextExtensions.startRunMetadata
expect(startRunMetadata).toMatchObject({
subject: {
kind: 'sim_user',
userId: 'consumer-1',
email: 'a@corp.com',
},
userEmail: 'a@corp.com',
workspaceId: 'workspace-consumer',
workflowId: 'parent-workflow-id',
Expand All @@ -743,6 +749,11 @@ describe('WorkflowBlockHandler', () => {
metadata: { id: 'custom_block_abc', name: 'Published Block' },
}
const inheritedMetadata = {
subject: {
kind: 'sim_user' as const,
userId: 'original-user',
email: 'original@corp.com',
},
userEmail: 'original@corp.com',
workspaceId: 'workspace-original',
workflowId: 'workflow-original',
Expand Down Expand Up @@ -822,6 +833,11 @@ describe('WorkflowBlockHandler', () => {

expect(executorOptions).toHaveLength(1)
expect(executorOptions[0].contextExtensions.startRunMetadata).toMatchObject({
subject: {
kind: 'sim_user',
userId: 'original-user',
email: 'original@corp.com',
},
userEmail: 'original@corp.com',
workspaceId: 'workspace-original',
workflowId: 'workflow-original',
Expand All @@ -830,12 +846,13 @@ describe('WorkflowBlockHandler', () => {
expect(mockGetUserEmailById).not.toHaveBeenCalled()
})

it('preserves a fail-soft null inherited email instead of re-resolving it', async () => {
it('preserves an actorless inherited subject instead of inventing an identity', async () => {
const ctx = {
...mockContext,
userId: 'publisher-1',
workspaceId: 'workspace-parent',
startRunMetadata: {
subject: null,
userEmail: null,
workspaceId: 'workspace-original',
workflowId: 'workflow-original',
Expand Down Expand Up @@ -876,12 +893,17 @@ describe('WorkflowBlockHandler', () => {
await handler.execute(ctx, mockBlock, inputs)

expect(executorOptions).toHaveLength(1)
expect(executorOptions[0].contextExtensions.startRunMetadata.subject).toBeNull()
expect(executorOptions[0].contextExtensions.startRunMetadata.userEmail).toBeNull()
expect(mockGetUserEmailById).not.toHaveBeenCalled()
})

it('recovers inherited metadata from the seeded start-block state after resume', async () => {
const seededMetadata = {
subject: {
kind: 'authenticated_email' as const,
email: 'original@corp.com',
},
userEmail: 'original@corp.com',
workspaceId: 'workspace-original',
workflowId: 'workflow-original',
Expand Down Expand Up @@ -944,6 +966,10 @@ describe('WorkflowBlockHandler', () => {

expect(executorOptions).toHaveLength(1)
expect(executorOptions[0].contextExtensions.startRunMetadata).toMatchObject({
subject: {
kind: 'authenticated_email',
email: 'original@corp.com',
},
userEmail: 'original@corp.com',
workspaceId: 'workspace-original',
workflowId: 'workflow-original',
Expand All @@ -953,6 +979,10 @@ describe('WorkflowBlockHandler', () => {

it('passes inherited metadata through a toggle-off child so deeper children keep it', async () => {
const inheritedMetadata = {
subject: {
kind: 'authenticated_email' as const,
email: 'original@corp.com',
},
userEmail: 'original@corp.com',
workspaceId: 'workspace-original',
workflowId: 'workflow-original',
Expand Down
Loading