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
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 @@ -228,7 +228,7 @@ export const PUT = withRouteHandler(
includeThinking: deployment.includeThinking ?? false,
includeToolCalls: deployment.includeToolCalls ?? false,
})
setChatAuthCookie(response, deployment, email)
await setChatAuthCookie(response, deployment, 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 @@ -416,6 +416,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
10 changes: 9 additions & 1 deletion apps/sim/app/api/chat/[identifier]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export const POST = withRouteHandler(
const response = createSuccessResponse(toChatConfigResponse(deployment))

if (deployment.authType === 'password') {
setChatAuthCookie(response, deployment)
await setChatAuthCookie(response, deployment)
}

return response
Expand Down Expand Up @@ -314,6 +314,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
80 changes: 58 additions & 22 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().mockResolvedValue(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,52 +84,65 @@ describe('Chat API Utils', () => {

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

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({
token: 'valid-token',
resource: deployment,
})
expect(result.authorized).toBe(true)
})

it('should reject invalid auth cookie via validateChatAuth', async () => {
mockValidateAuthToken.mockReturnValue(false)
mockReadDeploymentAuthToken.mockResolvedValue(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.mockResolvedValue({
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', () => {
it('should delegate to setDeploymentAuthCookie', () => {
it('should delegate to setDeploymentAuthCookie', async () => {
const mockResponse = {
cookies: { set: vi.fn() },
} as unknown as NextResponse
Expand All @@ -139,7 +152,7 @@ describe('Chat API Utils', () => {
authType: 'password',
password: 'encrypted-password',
}
setChatAuthCookie(mockResponse, deployment)
await setChatAuthCookie(mockResponse, deployment)

expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith({
response: mockResponse,
Expand All @@ -148,6 +161,26 @@ describe('Chat API Utils', () => {
verifiedEmail: undefined,
})
})

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

const deployment = {
id: 'test-chat-id',
authType: 'email',
allowedEmails: ['person@example.com'],
}
await setChatAuthCookie(mockResponse, deployment, 'person@example.com')

expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith({
response: mockResponse,
cookiePrefix: 'chat',
resource: deployment,
verifiedEmail: 'person@example.com',
})
})
})

describe('Chat auth validation', () => {
Expand Down Expand Up @@ -429,14 +462,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
6 changes: 3 additions & 3 deletions apps/sim/app/api/chat/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ import {
validateDeploymentAuth,
} from '@/lib/core/security/deployment-auth'

export function setChatAuthCookie(
export async function setChatAuthCookie(
response: NextResponse,
deployment: DeploymentAuthResource,
verifiedEmail?: string
): void {
setDeploymentAuthCookie({
): Promise<void> {
await setDeploymentAuthCookie({
response,
cookiePrefix: 'chat',
resource: deployment,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/files/public/[token]/otp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ export const PUT = withRouteHandler(
await deleteOTP('file', resolved.share.id, email)

const response = NextResponse.json({ authType: resolved.share.authType })
setDeploymentAuthCookie({
await setDeploymentAuthCookie({
response,
cookiePrefix: 'file',
resource: resolved.share,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/files/public/[token]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export const POST = withRouteHandler(
}

const response = NextResponse.json({ authType: resolved.share.authType })
setDeploymentAuthCookie({
await setDeploymentAuthCookie({
response,
cookiePrefix: 'file',
resource: resolved.share,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/f/[token]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ async function renderAuthGate(token: string, share: GateShare) {

const cookieStore = await cookies()
const cookieValue = cookieStore.get(deploymentAuthCookieName('file', share.id))?.value
if (validateAuthToken({ token: cookieValue ?? '', resource: share })) return null
if (await validateAuthToken({ token: cookieValue ?? '', resource: share })) return null

return share.authType === 'email' ? (
<PublicFileEmailAuth token={token} />
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, 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
40 changes: 31 additions & 9 deletions apps/sim/executor/handlers/workflow/workflow-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,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 @@ -805,7 +806,11 @@ describe('WorkflowBlockHandler', () => {
expect(executorOptions).toHaveLength(1)
const startRunMetadata = executorOptions[0].contextExtensions.startRunMetadata
expect(startRunMetadata).toMatchObject({
userEmail: 'a@corp.com',
subject: {
kind: 'sim_user',
userId: 'consumer-1',
email: 'a@corp.com',
},
workspaceId: 'workspace-consumer',
workflowId: 'parent-workflow-id',
executionId: 'exec-1',
Expand All @@ -823,7 +828,11 @@ describe('WorkflowBlockHandler', () => {
metadata: { id: 'custom_block_abc', name: 'Published Block' },
}
const inheritedMetadata = {
userEmail: 'original@corp.com',
subject: {
kind: 'sim_user' as const,
userId: 'original-user',
email: 'original@corp.com',
},
workspaceId: 'workspace-original',
workflowId: 'workflow-original',
executionId: 'exec-1',
Expand Down Expand Up @@ -903,21 +912,25 @@ describe('WorkflowBlockHandler', () => {

expect(executorOptions).toHaveLength(1)
expect(executorOptions[0].contextExtensions.startRunMetadata).toMatchObject({
userEmail: 'original@corp.com',
subject: {
kind: 'sim_user',
userId: 'original-user',
email: 'original@corp.com',
},
workspaceId: 'workspace-original',
workflowId: 'workflow-original',
executionMode: 'async',
})
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: {
userEmail: null,
subject: null,
workspaceId: 'workspace-original',
workflowId: 'workflow-original',
},
Expand Down Expand Up @@ -957,13 +970,16 @@ describe('WorkflowBlockHandler', () => {
await handler.execute(ctx, mockBlock, inputs)

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

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

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

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