Skip to content

Commit 68572ce

Browse files
fix(auth): harden subject delegation and cookies
1 parent e4114f3 commit 68572ce

4 files changed

Lines changed: 104 additions & 8 deletions

File tree

apps/sim/lib/auth/internal.test.ts

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
* @vitest-environment node
33
*/
44

5+
import { serializePrincipal } from '@sim/auth/principal'
56
import { resetEnvMock } from '@sim/testing'
6-
import { decodeJwt } from 'jose'
7+
import { decodeJwt, SignJWT } from 'jose'
78
import { afterAll, describe, expect, it, vi } from 'vitest'
9+
import { env } from '@/lib/core/config/env'
810

911
vi.unmock('@/lib/auth/internal')
1012

@@ -181,7 +183,32 @@ describe('internal executor delegation claims', () => {
181183
).rejects.toBeInstanceOf(InvalidInternalDelegationTokenError)
182184
})
183185

184-
it('rejects laundering actorless or external principals into a Sim user subject', async () => {
186+
it('round-trips an authenticated chat subject without inventing a Sim user', async () => {
187+
const token = await generateInternalDelegationToken({
188+
workflowId: 'workflow-1',
189+
principal: {
190+
kind: 'system',
191+
serviceId: 'chat',
192+
workspaceId: 'workspace-1',
193+
workflowId: 'workflow-1',
194+
subject: { kind: 'authenticated_email', email: 'person@example.com' },
195+
},
196+
})
197+
198+
await expect(verifyInternalDelegationToken(token)).resolves.toMatchObject({
199+
workflowId: 'workflow-1',
200+
principal: {
201+
kind: 'system',
202+
serviceId: 'chat',
203+
workspaceId: 'workspace-1',
204+
workflowId: 'workflow-1',
205+
subject: { kind: 'authenticated_email', email: 'person@example.com' },
206+
},
207+
})
208+
expect(decodeJwt(token).sub).toBeUndefined()
209+
})
210+
211+
it('rejects laundering actorless or non-Sim principals into a Sim user subject', async () => {
185212
await expect(
186213
generateInternalDelegationToken({
187214
subjectUserId: 'billing-owner',
@@ -213,7 +240,49 @@ describe('internal executor delegation claims', () => {
213240
},
214241
},
215242
})
216-
).rejects.toThrow('External workflow subjects cannot be represented as Sim users')
243+
).rejects.toThrow('Non-Sim workflow subjects cannot be represented as Sim users')
244+
245+
await expect(
246+
generateInternalDelegationToken({
247+
subjectUserId: 'unrelated-user',
248+
workflowId: 'workflow-1',
249+
principal: {
250+
kind: 'system',
251+
serviceId: 'chat',
252+
workspaceId: 'workspace-1',
253+
workflowId: 'workflow-1',
254+
subject: { kind: 'authenticated_email', email: 'person@example.com' },
255+
},
256+
})
257+
).rejects.toThrow('Non-Sim workflow subjects cannot be represented as Sim users')
258+
})
259+
260+
it('rejects a signed delegation that pairs a non-Sim principal with a Sim user subject', async () => {
261+
const issuedAt = Math.floor(Date.now() / 1000)
262+
const token = await new SignJWT({
263+
type: 'internal_delegation',
264+
serviceId: 'executor',
265+
workflowId: 'workflow-1',
266+
principal: serializePrincipal({
267+
kind: 'system',
268+
serviceId: 'chat',
269+
workspaceId: 'workspace-1',
270+
workflowId: 'workflow-1',
271+
subject: { kind: 'authenticated_email', email: 'person@example.com' },
272+
}),
273+
})
274+
.setProtectedHeader({ alg: 'HS256' })
275+
.setJti('delegation-1')
276+
.setSubject('unrelated-user')
277+
.setIssuedAt(issuedAt)
278+
.setExpirationTime(issuedAt + 5 * 60)
279+
.setIssuer('sim-internal')
280+
.setAudience('sim-api')
281+
.sign(new TextEncoder().encode(env.INTERNAL_API_SECRET))
282+
283+
await expect(verifyInternalDelegationToken(token)).rejects.toBeInstanceOf(
284+
InvalidInternalDelegationTokenError
285+
)
217286
})
218287

219288
it('derives issued-at and expiry from one timestamp', async () => {

apps/sim/lib/auth/internal.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,8 @@ export async function generateInternalDelegationToken(
138138
? requireNonEmptyDelegationClaim(input.subjectUserId, 'subjectUserId')
139139
: undefined
140140
const principalSubject = input.principal ? resolvePrincipalSubject(input.principal) : null
141-
if (principalSubject?.kind === 'external_user' && suppliedSubjectUserId) {
142-
throw new Error('External workflow subjects cannot be represented as Sim users')
141+
if (principalSubject && principalSubject.kind !== 'sim_user' && suppliedSubjectUserId) {
142+
throw new Error('Non-Sim workflow subjects cannot be represented as Sim users')
143143
}
144144
if (!principalSubject && input.principal && suppliedSubjectUserId) {
145145
throw new Error('Actorless workflow principals cannot be represented as Sim users')
@@ -247,7 +247,7 @@ export async function verifyInternalDelegationToken(
247247
if (
248248
(!principal && !subjectUserId) ||
249249
(principalSubject?.kind === 'sim_user' && principalSubject.userId !== subjectUserId) ||
250-
(principalSubject?.kind === 'external_user' && subjectUserId) ||
250+
(principalSubject && principalSubject.kind !== 'sim_user' && subjectUserId) ||
251251
(principal && !principalSubject && subjectUserId)
252252
) {
253253
throw new InvalidInternalDelegationTokenError()

apps/sim/lib/core/security/deployment.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
/**
22
* @vitest-environment node
33
*/
4+
5+
import { sha256Hex } from '@sim/security/hash'
6+
import { hmacSha256Hex } from '@sim/security/hmac'
47
import type { NextResponse } from 'next/server'
58
import { describe, expect, it, vi } from 'vitest'
9+
import { env } from '@/lib/core/config/env'
610
import {
711
isEmailAllowed,
812
readDeploymentAuthToken,
@@ -20,6 +24,17 @@ function mintDeploymentAuthToken(
2024
return set.mock.calls[0][0].value
2125
}
2226

27+
function mintLegacyDeploymentAuthToken(
28+
deploymentId: string,
29+
authType: string,
30+
encryptedPassword?: string
31+
): string {
32+
const passwordSlot = encryptedPassword ? sha256Hex(encryptedPassword).slice(0, 8) : ''
33+
const payload = `${deploymentId}:${authType}:${Date.now()}:${passwordSlot}`
34+
const signature = hmacSha256Hex(payload, env.BETTER_AUTH_SECRET)
35+
return Buffer.from(`${payload}:${signature}`).toString('base64')
36+
}
37+
2338
describe('deployment auth tokens', () => {
2439
it('round-trips the normalized email proven by OTP authentication', () => {
2540
const token = mintDeploymentAuthToken('chat-1', 'email', ' Person@Example.com ')
@@ -35,6 +50,18 @@ describe('deployment auth tokens', () => {
3550
expect(readDeploymentAuthToken(token, 'chat-1', 'password')).toEqual({})
3651
})
3752

53+
it('accepts a valid legacy password token without inventing identity', () => {
54+
const token = mintLegacyDeploymentAuthToken('chat-1', 'password', 'encrypted-password')
55+
56+
expect(readDeploymentAuthToken(token, 'chat-1', 'password', 'encrypted-password')).toEqual({})
57+
})
58+
59+
it('rejects a legacy email token that cannot prove an email identity', () => {
60+
const token = mintLegacyDeploymentAuthToken('chat-1', 'email')
61+
62+
expect(readDeploymentAuthToken(token, 'chat-1', 'email')).toBeNull()
63+
})
64+
3865
it('rejects a token outside its bound deployment and authentication type', () => {
3966
const token = mintDeploymentAuthToken('chat-1', 'email', 'person@example.com')
4067

apps/sim/lib/core/security/deployment.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ export function readDeploymentAuthToken(
6767
if (!safeCompare(sig, signPayload(payload))) return null
6868

6969
const parts = payload.split(':')
70-
if (parts.length !== 5) return null
71-
const [storedId, storedType, timestamp, storedPwSlot, storedEmailSlot] = parts
70+
if (parts.length !== 4 && parts.length !== 5) return null
71+
const [storedId, storedType, timestamp, storedPwSlot, storedEmailSlot = ''] = parts
7272

7373
if (storedId !== deploymentId || storedType !== authType) return null
7474
if (storedPwSlot !== passwordSlot(encryptedPassword)) return null

0 commit comments

Comments
 (0)