Skip to content

Commit 7e85965

Browse files
feat(workflows): expose authenticated run subjects (#7088)
* feat(workflows): expose authenticated run subjects * fix(tests): use typed chat auth requests * fix(auth): harden subject delegation and cookies * fix(workflows): remove duplicate run email metadata
1 parent 3a51850 commit 7e85965

28 files changed

Lines changed: 735 additions & 174 deletions

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ export const PUT = withRouteHandler(
228228
includeThinking: deployment.includeThinking ?? false,
229229
includeToolCalls: deployment.includeToolCalls ?? false,
230230
})
231-
setChatAuthCookie(response, deployment, email)
231+
await setChatAuthCookie(response, deployment, email)
232232

233233
return response
234234
} catch (error) {

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,39 @@ describe('Chat Identifier API Route', () => {
416416
)
417417
}, 10000)
418418

419+
it('executes with the email proven by the chat authentication gate', async () => {
420+
mockValidateChatAuth.mockResolvedValueOnce({
421+
authorized: true,
422+
authenticatedEmail: 'person@example.com',
423+
})
424+
const req = createMockNextRequest('POST', { input: 'Hello world' })
425+
426+
const response = await POST(req, {
427+
params: Promise.resolve({ identifier: 'test-chat' }),
428+
})
429+
expect(response.status).toBe(200)
430+
431+
const streamOptions = vi.mocked(createStreamingResponse).mock.calls[0][0]
432+
await streamOptions.executeFn({
433+
onStream: vi.fn(),
434+
onBlockComplete: vi.fn(),
435+
abortSignal: new AbortController().signal,
436+
})
437+
438+
expect(vi.mocked(executeWorkflow).mock.calls[0][4]).toMatchObject({
439+
principal: {
440+
kind: 'system',
441+
serviceId: 'chat',
442+
workspaceId: 'test-workspace-id',
443+
workflowId: 'workflow-id',
444+
subject: {
445+
kind: 'authenticated_email',
446+
email: 'person@example.com',
447+
},
448+
},
449+
})
450+
}, 10000)
451+
419452
/**
420453
* A row predating the column has no tool policy, so it has not opted in.
421454
* Thinking must not drag tool frames along with it.

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export const POST = withRouteHandler(
159159
const response = createSuccessResponse(toChatConfigResponse(deployment))
160160

161161
if (deployment.authType === 'password') {
162-
setChatAuthCookie(response, deployment)
162+
await setChatAuthCookie(response, deployment)
163163
}
164164

165165
return response
@@ -328,6 +328,14 @@ export const POST = withRouteHandler(
328328
serviceId: 'chat',
329329
workspaceId,
330330
workflowId: deployment.workflowId,
331+
...(authResult.authenticatedEmail
332+
? {
333+
subject: {
334+
kind: 'authenticated_email' as const,
335+
email: authResult.authenticatedEmail,
336+
},
337+
}
338+
: {}),
331339
},
332340
selectedOutputs,
333341
isSecureMode: true,

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

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
1818
const {
1919
mockMergeSubblockStateWithValues,
2020
mockMergeSubBlockValues,
21-
mockValidateAuthToken,
21+
mockReadDeploymentAuthToken,
2222
mockSetDeploymentAuthCookie,
2323
mockIsEmailAllowed,
2424
mockCheckRateLimitDirect,
2525
} = vi.hoisted(() => ({
2626
mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
2727
mockMergeSubBlockValues: vi.fn().mockReturnValue({}),
28-
mockValidateAuthToken: vi.fn().mockReturnValue(false),
28+
mockReadDeploymentAuthToken: vi.fn().mockResolvedValue(null),
2929
mockSetDeploymentAuthCookie: vi.fn(),
3030
mockIsEmailAllowed: vi.fn(),
3131
mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }),
@@ -57,7 +57,7 @@ vi.mock('@sim/workflow-persistence/subblocks', () => ({
5757
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
5858

5959
vi.mock('@/lib/core/security/deployment', () => ({
60-
validateAuthToken: mockValidateAuthToken,
60+
readDeploymentAuthToken: mockReadDeploymentAuthToken,
6161
setDeploymentAuthCookie: mockSetDeploymentAuthCookie,
6262
isEmailAllowed: mockIsEmailAllowed,
6363
deploymentAuthCookieName: (prefix: string, id: string) => `${prefix}_auth_${id}`,
@@ -84,52 +84,65 @@ describe('Chat API Utils', () => {
8484

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

8989
const deployment = {
9090
id: 'chat-id',
9191
authType: 'password',
9292
password: 'encrypted-password',
9393
}
9494

95-
const mockRequest = {
96-
method: 'POST',
97-
cookies: {
98-
get: vi.fn().mockReturnValue({ value: 'valid-token' }),
99-
},
100-
} as any
95+
const mockRequest = createMockRequest('POST', undefined, {
96+
cookie: 'chat_auth_chat-id=valid-token',
97+
})
10198

10299
const result = await validateChatAuth('request-id', deployment, mockRequest)
103-
expect(mockValidateAuthToken).toHaveBeenCalledWith({
100+
expect(mockReadDeploymentAuthToken).toHaveBeenCalledWith({
104101
token: 'valid-token',
105102
resource: deployment,
106103
})
107104
expect(result.authorized).toBe(true)
108105
})
109106

110107
it('should reject invalid auth cookie via validateChatAuth', async () => {
111-
mockValidateAuthToken.mockReturnValue(false)
108+
mockReadDeploymentAuthToken.mockResolvedValue(null)
112109

113110
const deployment = {
114111
id: 'chat-id',
115112
authType: 'password',
116113
password: 'encrypted-password',
117114
}
118115

119-
const mockRequest = {
120-
method: 'GET',
121-
cookies: {
122-
get: vi.fn().mockReturnValue({ value: 'invalid-token' }),
123-
},
124-
} as any
116+
const mockRequest = createMockRequest('GET', undefined, {
117+
cookie: 'chat_auth_chat-id=invalid-token',
118+
})
125119

126120
const result = await validateChatAuth('request-id', deployment, mockRequest)
127121
expect(result.authorized).toBe(false)
128122
})
123+
124+
it('returns the authenticated email carried by a valid email-auth cookie', async () => {
125+
mockReadDeploymentAuthToken.mockResolvedValue({
126+
authenticatedEmail: 'person@example.com',
127+
})
128+
129+
const deployment = {
130+
id: 'chat-id',
131+
authType: 'email',
132+
}
133+
const mockRequest = createMockRequest('POST', undefined, {
134+
cookie: 'chat_auth_chat-id=valid-token',
135+
})
136+
137+
await expect(validateChatAuth('request-id', deployment, mockRequest)).resolves.toEqual({
138+
authorized: true,
139+
authenticatedEmail: 'person@example.com',
140+
})
141+
})
129142
})
130143

131144
describe('Cookie handling', () => {
132-
it('should delegate to setDeploymentAuthCookie', () => {
145+
it('should delegate to setDeploymentAuthCookie', async () => {
133146
const mockResponse = {
134147
cookies: { set: vi.fn() },
135148
} as unknown as NextResponse
@@ -139,7 +152,7 @@ describe('Chat API Utils', () => {
139152
authType: 'password',
140153
password: 'encrypted-password',
141154
}
142-
setChatAuthCookie(mockResponse, deployment)
155+
await setChatAuthCookie(mockResponse, deployment)
143156

144157
expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith({
145158
response: mockResponse,
@@ -148,6 +161,26 @@ describe('Chat API Utils', () => {
148161
verifiedEmail: undefined,
149162
})
150163
})
164+
165+
it('forwards an authenticated email into the signed deployment cookie', async () => {
166+
const mockResponse = {
167+
cookies: { set: vi.fn() },
168+
} as unknown as NextResponse
169+
170+
const deployment = {
171+
id: 'test-chat-id',
172+
authType: 'email',
173+
allowedEmails: ['person@example.com'],
174+
}
175+
await setChatAuthCookie(mockResponse, deployment, 'person@example.com')
176+
177+
expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith({
178+
response: mockResponse,
179+
cookiePrefix: 'chat',
180+
resource: deployment,
181+
verifiedEmail: 'person@example.com',
182+
})
183+
})
151184
})
152185

153186
describe('Chat auth validation', () => {
@@ -429,14 +462,17 @@ describe('Chat API Utils', () => {
429462
})
430463

431464
it('authorizes execution when session email is allowlisted', async () => {
432-
mockGetSession.mockResolvedValue({ user: { email: 'user@example.com' } })
465+
mockGetSession.mockResolvedValue({ user: { email: 'User@Example.com' } })
433466
mockIsEmailAllowed.mockReturnValue(true)
434467

435468
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
436469
input: 'hello',
437470
})
438471

439-
expect(result.authorized).toBe(true)
472+
expect(result).toEqual({
473+
authorized: true,
474+
authenticatedEmail: 'user@example.com',
475+
})
440476
})
441477

442478
it('rejects execution when session email is not allowlisted', async () => {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ import {
1313
validateDeploymentAuth,
1414
} from '@/lib/core/security/deployment-auth'
1515

16-
export function setChatAuthCookie(
16+
export async function setChatAuthCookie(
1717
response: NextResponse,
1818
deployment: DeploymentAuthResource,
1919
verifiedEmail?: string
20-
): void {
21-
setDeploymentAuthCookie({
20+
): Promise<void> {
21+
await setDeploymentAuthCookie({
2222
response,
2323
cookiePrefix: 'chat',
2424
resource: deployment,

apps/sim/app/api/files/public/[token]/otp/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ export const PUT = withRouteHandler(
200200
await deleteOTP('file', resolved.share.id, email)
201201

202202
const response = NextResponse.json({ authType: resolved.share.authType })
203-
setDeploymentAuthCookie({
203+
await setDeploymentAuthCookie({
204204
response,
205205
cookiePrefix: 'file',
206206
resource: resolved.share,

apps/sim/app/api/files/public/[token]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export const POST = withRouteHandler(
124124
}
125125

126126
const response = NextResponse.json({ authType: resolved.share.authType })
127-
setDeploymentAuthCookie({
127+
await setDeploymentAuthCookie({
128128
response,
129129
cookiePrefix: 'file',
130130
resource: resolved.share,

apps/sim/app/f/[token]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ async function renderAuthGate(token: string, share: GateShare) {
9191

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

9696
return share.authType === 'email' ? (
9797
<PublicFileEmailAuth token={token} />

apps/sim/blocks/blocks/start_trigger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const StartTriggerBlock: BlockConfig = {
3232
mode: 'advanced',
3333
defaultValue: false,
3434
description:
35-
'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.',
35+
'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.',
3636
},
3737
],
3838
tools: {

apps/sim/executor/handlers/workflow/workflow-handler.test.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,7 @@ describe('WorkflowBlockHandler', () => {
801801
const ctx = {
802802
...mockContext,
803803
userId: 'consumer-1',
804+
principal: { kind: 'session', userId: 'consumer-1', sessionId: 'session-consumer' },
804805
workspaceId: 'workspace-consumer',
805806
executionId: 'exec-1',
806807
} as ExecutionContext
@@ -873,7 +874,11 @@ describe('WorkflowBlockHandler', () => {
873874
expect(executorOptions).toHaveLength(1)
874875
const startRunMetadata = executorOptions[0].contextExtensions.startRunMetadata
875876
expect(startRunMetadata).toMatchObject({
876-
userEmail: 'a@corp.com',
877+
subject: {
878+
kind: 'sim_user',
879+
userId: 'consumer-1',
880+
email: 'a@corp.com',
881+
},
877882
workspaceId: 'workspace-consumer',
878883
workflowId: 'parent-workflow-id',
879884
executionId: 'exec-1',
@@ -891,7 +896,11 @@ describe('WorkflowBlockHandler', () => {
891896
metadata: { id: 'custom_block_abc', name: 'Published Block' },
892897
}
893898
const inheritedMetadata = {
894-
userEmail: 'original@corp.com',
899+
subject: {
900+
kind: 'sim_user' as const,
901+
userId: 'original-user',
902+
email: 'original@corp.com',
903+
},
895904
workspaceId: 'workspace-original',
896905
workflowId: 'workflow-original',
897906
executionId: 'exec-1',
@@ -971,21 +980,25 @@ describe('WorkflowBlockHandler', () => {
971980

972981
expect(executorOptions).toHaveLength(1)
973982
expect(executorOptions[0].contextExtensions.startRunMetadata).toMatchObject({
974-
userEmail: 'original@corp.com',
983+
subject: {
984+
kind: 'sim_user',
985+
userId: 'original-user',
986+
email: 'original@corp.com',
987+
},
975988
workspaceId: 'workspace-original',
976989
workflowId: 'workflow-original',
977990
executionMode: 'async',
978991
})
979992
expect(mockGetUserEmailById).not.toHaveBeenCalled()
980993
})
981994

982-
it('preserves a fail-soft null inherited email instead of re-resolving it', async () => {
995+
it('preserves an actorless inherited subject instead of inventing an identity', async () => {
983996
const ctx = {
984997
...mockContext,
985998
userId: 'publisher-1',
986999
workspaceId: 'workspace-parent',
9871000
startRunMetadata: {
988-
userEmail: null,
1001+
subject: null,
9891002
workspaceId: 'workspace-original',
9901003
workflowId: 'workflow-original',
9911004
},
@@ -1025,13 +1038,16 @@ describe('WorkflowBlockHandler', () => {
10251038
await handler.execute(ctx, mockBlock, inputs)
10261039

10271040
expect(executorOptions).toHaveLength(1)
1028-
expect(executorOptions[0].contextExtensions.startRunMetadata.userEmail).toBeNull()
1041+
expect(executorOptions[0].contextExtensions.startRunMetadata.subject).toBeNull()
10291042
expect(mockGetUserEmailById).not.toHaveBeenCalled()
10301043
})
10311044

10321045
it('recovers inherited metadata from the seeded start-block state after resume', async () => {
10331046
const seededMetadata = {
1034-
userEmail: 'original@corp.com',
1047+
subject: {
1048+
kind: 'authenticated_email' as const,
1049+
email: 'original@corp.com',
1050+
},
10351051
workspaceId: 'workspace-original',
10361052
workflowId: 'workflow-original',
10371053
executionMode: 'sync',
@@ -1093,7 +1109,10 @@ describe('WorkflowBlockHandler', () => {
10931109

10941110
expect(executorOptions).toHaveLength(1)
10951111
expect(executorOptions[0].contextExtensions.startRunMetadata).toMatchObject({
1096-
userEmail: 'original@corp.com',
1112+
subject: {
1113+
kind: 'authenticated_email',
1114+
email: 'original@corp.com',
1115+
},
10971116
workspaceId: 'workspace-original',
10981117
workflowId: 'workflow-original',
10991118
})
@@ -1102,7 +1121,10 @@ describe('WorkflowBlockHandler', () => {
11021121

11031122
it('passes inherited metadata through a toggle-off child so deeper children keep it', async () => {
11041123
const inheritedMetadata = {
1105-
userEmail: 'original@corp.com',
1124+
subject: {
1125+
kind: 'authenticated_email' as const,
1126+
email: 'original@corp.com',
1127+
},
11061128
workspaceId: 'workspace-original',
11071129
workflowId: 'workflow-original',
11081130
}

0 commit comments

Comments
 (0)