From 5ad2d8d5317ac2faf96e52846169ef8281f2e2de Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 15:54:50 -0700 Subject: [PATCH 1/2] feat(auth): add scoped internal executor delegation --- .../[id]/files/[fileId]/csv-preview/route.ts | 4 +- apps/sim/lib/api/server/routes/index.ts | 2 +- .../api/server/routes/internal-json-route.ts | 55 ++++++-- apps/sim/lib/auth/internal-delegation.test.ts | 115 +++++++++++++++++ apps/sim/lib/auth/internal-delegation.ts | 59 +++++++++ apps/sim/lib/auth/internal.test.ts | 51 +++++++- apps/sim/lib/auth/internal.ts | 118 +++++++++++++++++- apps/sim/lib/workspace-files/api/index.ts | 2 +- .../api/route-policies.test.ts | 113 ++++++++++++++--- .../lib/workspace-files/api/route-policies.ts | 26 ++-- packages/auth/src/principal.ts | 11 ++ 11 files changed, 505 insertions(+), 51 deletions(-) create mode 100644 apps/sim/lib/auth/internal-delegation.test.ts create mode 100644 apps/sim/lib/auth/internal-delegation.ts diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts index 100a7fadca0..de4b8c6104f 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { getWorkspaceCsvPreviewContract } from '@/lib/api/contracts/workspace-file-table' import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' -import { internalFileErrorPolicies, internalSessionOrServiceAuth } from '@/lib/workspace-files/api' +import { internalFileErrorPolicies, internalSessionOrExecutorAuth } from '@/lib/workspace-files/api' import { csvPreviewWorkspaceFile } from '@/lib/workspace-files/application/csv-preview-workspace-file' const logger = createLogger('WorkspaceCsvPreviewAPI') @@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic' export const GET = defineInternalJsonRoute({ contract: getWorkspaceCsvPreviewContract, - auth: internalSessionOrServiceAuth, + auth: internalSessionOrExecutorAuth, operation: csvPreviewWorkspaceFile.operation, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal CSV preview behavior' }), errorPolicy: internalFileErrorPolicies.plain, diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index 3eacbed3f3e..408ecff04d0 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -1,6 +1,6 @@ export { defineInternalBinaryRoute } from '@/lib/api/server/routes/internal-binary-route' export { - createInternalSessionOrServiceAuth, + createInternalSessionOrExecutorAuth, defineInternalJsonRoute, extendInternalErrorPolicy, type InternalAuthPolicy, diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts index 8191e353794..6563d5e7cdb 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -1,4 +1,9 @@ -import type { DelegatedPrincipal, Principal, SessionPrincipal } from '@sim/auth/principal' +import type { + DelegatedPrincipal, + Principal, + SessionPrincipal, + WorkflowExecutionDelegatedPrincipal, +} from '@sim/auth/principal' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import type { ContractJsonResponse } from '@/lib/api/contracts' @@ -15,7 +20,14 @@ import { parseRequest, } from '@/lib/api/server/validation' import { getSession } from '@/lib/auth' -import { verifyInternalToken } from '@/lib/auth/internal' +import { + InvalidInternalDelegationTokenError, + verifyInternalDelegationToken, +} from '@/lib/auth/internal' +import { + bindInternalExecutorDelegation, + InvalidInternalDelegationBindingError, +} from '@/lib/auth/internal-delegation' import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -37,12 +49,18 @@ export const internalSessionAuth = { }, } as const -export function createInternalSessionOrServiceAuth

( - bindDelegation: (args: { - subjectUserId: string +export interface InternalSessionOrExecutorAuthOptions { + audience: string + resourceScope?( params: Record - }) => P -): InternalAuthPolicy { + ): DelegatedPrincipal['resourceScope'] +} + +export function createInternalSessionOrExecutorAuth( + options: InternalSessionOrExecutorAuthOptions +): InternalAuthPolicy { + if (!options.audience.trim()) throw new Error('Internal executor auth audience must not be empty') + return { async authenticate(request, params) { if (request.headers.has('x-api-key')) { @@ -50,13 +68,28 @@ export function createInternalSessionOrServiceAuth

} const authorization = request.headers.get('authorization') - if (!authorization?.startsWith('Bearer ')) return internalSessionAuth.authenticate() + if (!authorization) return internalSessionAuth.authenticate() + if (!authorization.startsWith('Bearer ')) { + throw new InternalUnauthenticatedError('Authentication required') + } - const verification = await verifyInternalToken(authorization.slice('Bearer '.length)) - if (!verification.valid || !verification.userId) { + let delegation + try { + delegation = await verifyInternalDelegationToken(authorization.slice('Bearer '.length)) + } catch (error) { + if (!(error instanceof InvalidInternalDelegationTokenError)) throw error + throw new InternalUnauthenticatedError('Authentication required') + } + + try { + return await bindInternalExecutorDelegation(delegation, { + audience: options.audience, + resourceScope: options.resourceScope?.(params), + }) + } catch (error) { + if (!(error instanceof InvalidInternalDelegationBindingError)) throw error throw new InternalUnauthenticatedError('Authentication required') } - return bindDelegation({ subjectUserId: verification.userId, params }) }, } } diff --git a/apps/sim/lib/auth/internal-delegation.test.ts b/apps/sim/lib/auth/internal-delegation.test.ts new file mode 100644 index 00000000000..a041ec712a8 --- /dev/null +++ b/apps/sim/lib/auth/internal-delegation.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockResolveWorkflow, mockResolveRun } = vi.hoisted(() => ({ + mockResolveWorkflow: vi.fn(), + mockResolveRun: vi.fn(), +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mockResolveWorkflow, + resolveActiveWorkflowRunApplicationContext: mockResolveRun, +})) + +import { + bindInternalExecutorDelegation, + InvalidInternalDelegationBindingError, +} from '@/lib/auth/internal-delegation' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const claims = { + serviceId: 'executor' as const, + subjectUserId: 'user-1', + workflowId: 'workflow-1', + delegationId: 'delegation-1', + issuedAt: new Date('2026-08-08T12:00:00.000Z'), + expiresAt: new Date('2026-08-08T12:05:00.000Z'), +} + +describe('bindInternalExecutorDelegation', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveWorkflow.mockResolvedValue({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + }) + mockResolveRun.mockResolvedValue({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + runId: 'execution-1', + }) + }) + + it('derives workspace authority from the canonical workflow', async () => { + await expect( + bindInternalExecutorDelegation(claims, { audience: 'sim:knowledge' }) + ).resolves.toEqual({ + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:knowledge', + issuedAt: claims.issuedAt, + expiresAt: claims.expiresAt, + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + }, + }) + expect(mockResolveWorkflow).toHaveBeenCalledWith({ workflowId: 'workflow-1' }) + expect(mockResolveRun).not.toHaveBeenCalled() + }) + + it('canonically binds an execution to its signed workflow', async () => { + const executionClaims = { ...claims, executionId: 'execution-1' } + + const principal = await bindInternalExecutorDelegation(executionClaims, { + audience: 'sim:workspace-files', + resourceScope: { fileId: 'file-1' }, + }) + + expect(mockResolveRun).toHaveBeenCalledWith({ + runId: 'execution-1', + assertedWorkflowId: 'workflow-1', + }) + expect(principal).toMatchObject({ + workspaceId: 'workspace-1', + resourceScope: { fileId: 'file-1' }, + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + }) + + it('fails before canonical loading when the domain audience is missing', async () => { + await expect(bindInternalExecutorDelegation(claims, { audience: ' ' })).rejects.toThrow( + 'Internal delegation audience must not be empty' + ) + expect(mockResolveWorkflow).not.toHaveBeenCalled() + }) + + it('classifies a missing canonical execution as an invalid delegation binding', async () => { + mockResolveRun.mockRejectedValue(new OrchestrationError('not_found', 'Workflow run not found')) + + await expect( + bindInternalExecutorDelegation( + { ...claims, executionId: 'execution-1' }, + { audience: 'sim:workspace-files' } + ) + ).rejects.toBeInstanceOf(InvalidInternalDelegationBindingError) + }) + + it('does not disguise canonical-load infrastructure failures as invalid credentials', async () => { + const infrastructureError = new Error('database unavailable') + mockResolveWorkflow.mockRejectedValue(infrastructureError) + + await expect( + bindInternalExecutorDelegation(claims, { audience: 'sim:workspace-files' }) + ).rejects.toBe(infrastructureError) + }) +}) diff --git a/apps/sim/lib/auth/internal-delegation.ts b/apps/sim/lib/auth/internal-delegation.ts new file mode 100644 index 00000000000..b37b4418c8a --- /dev/null +++ b/apps/sim/lib/auth/internal-delegation.ts @@ -0,0 +1,59 @@ +import type { DelegatedPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import type { VerifiedInternalDelegation } from '@/lib/auth/internal' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { + resolveActiveWorkflowApplicationContext, + resolveActiveWorkflowRunApplicationContext, +} from '@/lib/workflows/application/context' + +export interface BindInternalExecutorDelegationOptions { + audience: string + resourceScope?: DelegatedPrincipal['resourceScope'] +} + +export class InvalidInternalDelegationBindingError extends Error { + constructor() { + super('Internal delegation no longer resolves to an active workflow execution') + this.name = 'InvalidInternalDelegationBindingError' + } +} + +/** Binds signed executor claims to the workflow's canonical active workspace. */ +export async function bindInternalExecutorDelegation( + claims: VerifiedInternalDelegation, + options: BindInternalExecutorDelegationOptions +): Promise { + if (!options.audience.trim()) throw new Error('Internal delegation audience must not be empty') + + let context + try { + context = claims.executionId + ? await resolveActiveWorkflowRunApplicationContext({ + runId: claims.executionId, + assertedWorkflowId: claims.workflowId, + }) + : await resolveActiveWorkflowApplicationContext({ workflowId: claims.workflowId }) + } catch (error) { + if (asOrchestrationError(error)?.code === 'not_found') { + throw new InvalidInternalDelegationBindingError() + } + throw error + } + + return { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: claims.subjectUserId, + workspaceId: context.workspaceId, + delegationId: claims.delegationId, + audience: options.audience, + issuedAt: claims.issuedAt, + expiresAt: claims.expiresAt, + ...(options.resourceScope ? { resourceScope: options.resourceScope } : {}), + delegationContext: { + kind: 'workflow_execution', + workflowId: context.workflowId, + ...(claims.executionId ? { executionId: claims.executionId } : {}), + }, + } +} diff --git a/apps/sim/lib/auth/internal.test.ts b/apps/sim/lib/auth/internal.test.ts index fcabbb47c01..6c0e22d6baf 100644 --- a/apps/sim/lib/auth/internal.test.ts +++ b/apps/sim/lib/auth/internal.test.ts @@ -7,7 +7,13 @@ import { afterAll, describe, expect, it, vi } from 'vitest' vi.unmock('@/lib/auth/internal') -import { generateInternalToken, verifyInternalToken } from '@/lib/auth/internal' +import { + generateInternalDelegationToken, + generateInternalToken, + InvalidInternalDelegationTokenError, + verifyInternalDelegationToken, + verifyInternalToken, +} from '@/lib/auth/internal' afterAll(resetEnvMock) @@ -39,3 +45,46 @@ describe('internal JWT claims', () => { await expect(verifyInternalToken(token)).resolves.toEqual({ valid: false }) }) }) + +describe('internal executor delegation claims', () => { + it('round-trips a subject-bearing workflow execution delegation', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + + const delegation = await verifyInternalDelegationToken(token) + + expect(delegation).toMatchObject({ + serviceId: 'executor', + subjectUserId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + expect(delegation.delegationId).toBeTruthy() + expect(delegation.issuedAt).toBeInstanceOf(Date) + expect(delegation.expiresAt.getTime()).toBeGreaterThan(delegation.issuedAt.getTime()) + }) + + it('rejects missing delegation scope at issuance', async () => { + await expect( + generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: ' ', + }) + ).rejects.toThrow('Internal delegation workflowId must not be empty') + }) + + it('does not accept legacy subject or actorless tokens as executor delegations', async () => { + const legacySubjectToken = await generateInternalToken('user-1') + const actorlessToken = await generateInternalToken() + + await expect(verifyInternalDelegationToken(legacySubjectToken)).rejects.toBeInstanceOf( + InvalidInternalDelegationTokenError + ) + await expect(verifyInternalDelegationToken(actorlessToken)).rejects.toBeInstanceOf( + InvalidInternalDelegationTokenError + ) + }) +}) diff --git a/apps/sim/lib/auth/internal.ts b/apps/sim/lib/auth/internal.ts index 1880efee202..b1742d0e6f5 100644 --- a/apps/sim/lib/auth/internal.ts +++ b/apps/sim/lib/auth/internal.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' -import { jwtVerify, SignJWT } from 'jose' +import { generateId } from '@sim/utils/id' +import { type JWTPayload, jwtVerify, SignJWT } from 'jose' import { type NextRequest, NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' import { getClientIp } from '@/lib/core/utils/request' @@ -14,6 +15,34 @@ export interface InternalTokenClaims { sandboxProfile?: InternalSandboxProfile } +export interface GenerateInternalDelegationTokenInput { + subjectUserId: string + workflowId: string + executionId?: string +} + +export interface VerifiedInternalDelegation { + serviceId: 'executor' + subjectUserId: string + workflowId: string + executionId?: string + delegationId: string + issuedAt: Date + expiresAt: Date +} + +export class InvalidInternalDelegationTokenError extends Error { + constructor(message = 'Invalid internal delegation token') { + super(message) + this.name = 'InvalidInternalDelegationTokenError' + } +} + +const INTERNAL_DELEGATION_ISSUER = 'sim-internal' +const INTERNAL_DELEGATION_AUDIENCE = 'sim-api' +const INTERNAL_DELEGATION_TTL_SECONDS = 5 * 60 +const INTERNAL_DELEGATION_CLOCK_TOLERANCE_SECONDS = 5 + const getJwtSecret = () => { // Prefer a dedicated JWT signing key so the internal-JWT trust domain is // separable from the raw INTERNAL_API_SECRET shared-bearer secret: leaking one @@ -57,6 +86,93 @@ export async function generateInternalToken( return token } +function requireNonEmptyDelegationClaim(value: string, name: string): string { + if (!value.trim()) throw new Error(`Internal delegation ${name} must not be empty`) + return value +} + +/** Generates a subject-bearing executor token bound to a workflow and optional execution origin. */ +export async function generateInternalDelegationToken( + input: GenerateInternalDelegationTokenInput +): Promise { + const subjectUserId = requireNonEmptyDelegationClaim(input.subjectUserId, 'subjectUserId') + const workflowId = requireNonEmptyDelegationClaim(input.workflowId, 'workflowId') + const executionId = input.executionId + ? requireNonEmptyDelegationClaim(input.executionId, 'executionId') + : undefined + + return new SignJWT({ + type: 'internal_delegation', + serviceId: 'executor', + workflowId, + ...(executionId ? { executionId } : {}), + }) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(subjectUserId) + .setJti(generateId()) + .setIssuedAt() + .setExpirationTime(`${INTERNAL_DELEGATION_TTL_SECONDS}s`) + .setIssuer(INTERNAL_DELEGATION_ISSUER) + .setAudience(INTERNAL_DELEGATION_AUDIENCE) + .sign(getJwtSecret()) +} + +function readVerifiedDelegationClaim(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null +} + +/** Verifies a scoped executor delegation without accepting legacy or actorless tokens. */ +export async function verifyInternalDelegationToken( + token: string +): Promise { + const secret = getJwtSecret() + let payload: JWTPayload + try { + const verification = await jwtVerify(token, secret, { + issuer: INTERNAL_DELEGATION_ISSUER, + audience: INTERNAL_DELEGATION_AUDIENCE, + algorithms: ['HS256'], + clockTolerance: INTERNAL_DELEGATION_CLOCK_TOLERANCE_SECONDS, + }) + payload = verification.payload + } catch { + throw new InvalidInternalDelegationTokenError() + } + + const subjectUserId = readVerifiedDelegationClaim(payload.sub) + const workflowId = readVerifiedDelegationClaim(payload.workflowId) + const executionId = + payload.executionId === undefined ? undefined : readVerifiedDelegationClaim(payload.executionId) + const delegationId = readVerifiedDelegationClaim(payload.jti) + const nowSeconds = Math.floor(Date.now() / 1000) + + if ( + payload.type !== 'internal_delegation' || + payload.serviceId !== 'executor' || + !subjectUserId || + !workflowId || + executionId === null || + !delegationId || + typeof payload.iat !== 'number' || + typeof payload.exp !== 'number' || + payload.iat > nowSeconds + INTERNAL_DELEGATION_CLOCK_TOLERANCE_SECONDS || + payload.exp <= payload.iat || + payload.exp - payload.iat > INTERNAL_DELEGATION_TTL_SECONDS + ) { + throw new InvalidInternalDelegationTokenError() + } + + return { + serviceId: 'executor', + subjectUserId, + workflowId, + ...(executionId ? { executionId } : {}), + delegationId, + issuedAt: new Date(payload.iat * 1000), + expiresAt: new Date(payload.exp * 1000), + } +} + /** * Verify an internal JWT token * Returns verification result with userId if present in token diff --git a/apps/sim/lib/workspace-files/api/index.ts b/apps/sim/lib/workspace-files/api/index.ts index 2b23ed91382..dd0b8f04359 100644 --- a/apps/sim/lib/workspace-files/api/index.ts +++ b/apps/sim/lib/workspace-files/api/index.ts @@ -2,6 +2,6 @@ export { internalFileAnalytics } from '@/lib/workspace-files/api/internal-analyt export { internalFileErrorPolicies } from '@/lib/workspace-files/api/internal-error-policies' export { internalFilePresenters } from '@/lib/workspace-files/api/internal-presenters' export { - internalSessionOrServiceAuth, + internalSessionOrExecutorAuth, v2FileErrorPolicies, } from '@/lib/workspace-files/api/route-policies' diff --git a/apps/sim/lib/workspace-files/api/route-policies.test.ts b/apps/sim/lib/workspace-files/api/route-policies.test.ts index 3fd91c612bb..3ab9860d5f5 100644 --- a/apps/sim/lib/workspace-files/api/route-policies.test.ts +++ b/apps/sim/lib/workspace-files/api/route-policies.test.ts @@ -1,32 +1,65 @@ /** * @vitest-environment node */ + +import { resetEnvMock } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockVerifyInternalToken } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockVerifyInternalToken: vi.fn(), -})) +const { MockInvalidBindingError, mockBindDelegation, mockGetSession } = vi.hoisted(() => { + class MockInvalidBindingError extends Error {} + return { + MockInvalidBindingError, + mockBindDelegation: vi.fn(), + mockGetSession: vi.fn(), + } +}) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) -vi.mock('@/lib/auth/internal', () => ({ verifyInternalToken: mockVerifyInternalToken })) +vi.mock('@/lib/auth/internal-delegation', () => ({ + bindInternalExecutorDelegation: mockBindDelegation, + InvalidInternalDelegationBindingError: MockInvalidBindingError, +})) +vi.unmock('@/lib/auth/internal') import { InternalUnauthenticatedError } from '@/lib/api/server/routes' -import { internalSessionOrServiceAuth } from '@/lib/workspace-files/api' +import { generateInternalDelegationToken, generateInternalToken } from '@/lib/auth/internal' +import { internalSessionOrExecutorAuth } from '@/lib/workspace-files/api' + +afterAll(resetEnvMock) describe('internal file route authentication', () => { beforeEach(() => { vi.clearAllMocks() mockGetSession.mockResolvedValue(null) + mockBindDelegation.mockImplementation(async (delegation, options) => ({ + kind: 'delegated', + serviceId: 'executor', + subjectUserId: delegation.subjectUserId, + workspaceId: 'canonical-workspace', + delegationId: delegation.delegationId, + audience: options.audience, + issuedAt: delegation.issuedAt, + expiresAt: delegation.expiresAt, + resourceScope: options.resourceScope, + delegationContext: { + kind: 'workflow_execution', + workflowId: delegation.workflowId, + executionId: delegation.executionId, + }, + })) }) - it('binds a verified internal user to an executor file principal', async () => { - mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-1' }) + it('binds a scoped executor token without trusting the workspace route parameter', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) - const principal = await internalSessionOrServiceAuth.authenticate( + const principal = await internalSessionOrExecutorAuth.authenticate( new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', { - headers: { authorization: 'Bearer signed-token' }, + headers: { authorization: `Bearer ${token}` }, }), { id: 'ws-1', fileId: 'file-1' } ) @@ -35,26 +68,72 @@ describe('internal file route authentication', () => { kind: 'delegated', serviceId: 'executor', subjectUserId: 'user-1', - workspaceId: 'ws-1', + workspaceId: 'canonical-workspace', audience: 'sim:workspace-files', resourceScope: { fileId: 'file-1' }, }) + expect(mockBindDelegation).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-1', + executionId: 'execution-1', + }), + { + audience: 'sim:workspace-files', + resourceScope: { fileId: 'file-1' }, + } + ) expect(mockGetSession).not.toHaveBeenCalled() }) - it('rejects internal tokens that do not carry a human subject', async () => { - mockVerifyInternalToken.mockResolvedValue({ valid: true }) + it('rejects legacy actorless internal tokens before canonical binding', async () => { + const token = await generateInternalToken() + + await expect( + internalSessionOrExecutorAuth.authenticate( + new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', { + headers: { authorization: `Bearer ${token}` }, + }), + { id: 'ws-1', fileId: 'file-1' } + ) + ).rejects.toBeInstanceOf(InternalUnauthenticatedError) + expect(mockBindDelegation).not.toHaveBeenCalled() + }) + + it('rejects a scoped token whose canonical workflow binding no longer exists', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }) + mockBindDelegation.mockRejectedValue(new MockInvalidBindingError()) await expect( - internalSessionOrServiceAuth.authenticate( + internalSessionOrExecutorAuth.authenticate( new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', { - headers: { authorization: 'Bearer signed-token' }, + headers: { authorization: `Bearer ${token}` }, }), { id: 'ws-1', fileId: 'file-1' } ) ).rejects.toBeInstanceOf(InternalUnauthenticatedError) }) + it('does not render canonical-binding infrastructure failures as bad credentials', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }) + const infrastructureError = new Error('database unavailable') + mockBindDelegation.mockRejectedValue(infrastructureError) + + await expect( + internalSessionOrExecutorAuth.authenticate( + new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', { + headers: { authorization: `Bearer ${token}` }, + }), + { id: 'ws-1', fileId: 'file-1' } + ) + ).rejects.toBe(infrastructureError) + }) + it('preserves browser session principals when no service token is supplied', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-1' }, @@ -62,7 +141,7 @@ describe('internal file route authentication', () => { }) await expect( - internalSessionOrServiceAuth.authenticate( + internalSessionOrExecutorAuth.authenticate( new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1'), { id: 'ws-1', fileId: 'file-1' } ) diff --git a/apps/sim/lib/workspace-files/api/route-policies.ts b/apps/sim/lib/workspace-files/api/route-policies.ts index c287586ddd2..6ec9c725b04 100644 --- a/apps/sim/lib/workspace-files/api/route-policies.ts +++ b/apps/sim/lib/workspace-files/api/route-policies.ts @@ -1,26 +1,18 @@ import { - createInternalSessionOrServiceAuth, + createInternalSessionOrExecutorAuth, type V2ErrorPolicy, v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' -import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' -export const internalSessionOrServiceAuth = createInternalSessionOrServiceAuth( - ({ subjectUserId, params }) => { - const workspaceId = params.id - if (typeof workspaceId !== 'string' || !workspaceId) { - throw new Error('Internal file delegation requires a workspace route parameter') - } - return createWorkspaceFileDelegatedPrincipal({ - serviceId: 'executor', - subjectUserId, - workspaceId, - delegationId: `internal-file:${subjectUserId}`, - fileId: typeof params.fileId === 'string' ? params.fileId : undefined, - }) - } -) +export const internalSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({ + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, + resourceScope: (params) => { + const fileId = typeof params.fileId === 'string' ? params.fileId : undefined + return fileId ? { fileId } : undefined + }, +}) export const v2FileErrorPolicies = { default: v2OrchestrationErrorPolicy, diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index 62136ce8635..d87626272bd 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -39,6 +39,17 @@ export interface DelegatedPrincipal { } } +export interface WorkflowExecutionDelegationContext { + kind: 'workflow_execution' + workflowId: string + executionId?: string +} + +export type WorkflowExecutionDelegatedPrincipal = DelegatedPrincipal & { + serviceId: 'executor' + delegationContext: WorkflowExecutionDelegationContext +} + export type PrincipalActor = | { kind: 'session'; userId: string } | { kind: 'personal_api_key'; keyId: string; userId: string } From 62882402d0c314c00bfec16e754dd79ecb497470 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 16:03:37 -0700 Subject: [PATCH 2/2] fix(auth): derive delegation lifetime from one timestamp --- apps/sim/lib/auth/internal.test.ts | 14 ++++++++++++++ apps/sim/lib/auth/internal.ts | 5 +++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/auth/internal.test.ts b/apps/sim/lib/auth/internal.test.ts index 6c0e22d6baf..1f51376c7fc 100644 --- a/apps/sim/lib/auth/internal.test.ts +++ b/apps/sim/lib/auth/internal.test.ts @@ -3,6 +3,7 @@ */ import { resetEnvMock } from '@sim/testing' +import { decodeJwt } from 'jose' import { afterAll, describe, expect, it, vi } from 'vitest' vi.unmock('@/lib/auth/internal') @@ -67,6 +68,19 @@ describe('internal executor delegation claims', () => { expect(delegation.expiresAt.getTime()).toBeGreaterThan(delegation.issuedAt.getTime()) }) + it('derives issued-at and expiry from one timestamp', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }) + const payload = decodeJwt(token) + + if (typeof payload.exp !== 'number' || typeof payload.iat !== 'number') { + throw new Error('Generated delegation token is missing numeric lifetime claims') + } + expect(payload.exp - payload.iat).toBe(5 * 60) + }) + it('rejects missing delegation scope at issuance', async () => { await expect( generateInternalDelegationToken({ diff --git a/apps/sim/lib/auth/internal.ts b/apps/sim/lib/auth/internal.ts index b1742d0e6f5..eb28a0cc645 100644 --- a/apps/sim/lib/auth/internal.ts +++ b/apps/sim/lib/auth/internal.ts @@ -97,6 +97,7 @@ export async function generateInternalDelegationToken( ): Promise { const subjectUserId = requireNonEmptyDelegationClaim(input.subjectUserId, 'subjectUserId') const workflowId = requireNonEmptyDelegationClaim(input.workflowId, 'workflowId') + const issuedAtSeconds = Math.floor(Date.now() / 1000) const executionId = input.executionId ? requireNonEmptyDelegationClaim(input.executionId, 'executionId') : undefined @@ -110,8 +111,8 @@ export async function generateInternalDelegationToken( .setProtectedHeader({ alg: 'HS256' }) .setSubject(subjectUserId) .setJti(generateId()) - .setIssuedAt() - .setExpirationTime(`${INTERNAL_DELEGATION_TTL_SECONDS}s`) + .setIssuedAt(issuedAtSeconds) + .setExpirationTime(issuedAtSeconds + INTERNAL_DELEGATION_TTL_SECONDS) .setIssuer(INTERNAL_DELEGATION_ISSUER) .setAudience(INTERNAL_DELEGATION_AUDIENCE) .sign(getJwtSecret())