Skip to content

Commit beb24b2

Browse files
committed
feat(credentials): bind executor managed-OAuth delegations in-process
1 parent 572ce5f commit beb24b2

3 files changed

Lines changed: 144 additions & 2 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import type { ExecutorDelegationOrigin } from '@/executor/types'
6+
7+
const { mockBindInternalExecutorDelegation } = vi.hoisted(() => ({
8+
mockBindInternalExecutorDelegation: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/auth/internal-delegation', () => ({
12+
bindInternalExecutorDelegation: mockBindInternalExecutorDelegation,
13+
InvalidInternalDelegationBindingError: class InvalidInternalDelegationBindingError extends Error {},
14+
}))
15+
16+
vi.mock('@/lib/auth/internal', () => ({
17+
InvalidInternalDelegationTokenError: class InvalidInternalDelegationTokenError extends Error {},
18+
verifyInternalDelegationToken: vi.fn(),
19+
}))
20+
21+
import { InvalidInternalDelegationBindingError } from '@/lib/auth/internal-delegation'
22+
import {
23+
bindExecutorManagedOAuthDelegation,
24+
InvalidManagedOAuthDelegationError,
25+
} from '@/lib/credentials/application/managed-oauth-delegation'
26+
27+
function delegationOrigin(
28+
overrides: Partial<ExecutorDelegationOrigin> = {}
29+
): ExecutorDelegationOrigin {
30+
return {
31+
subjectUserId: 'user-origin',
32+
workflowId: 'workflow-origin',
33+
executionId: 'execution-origin',
34+
currentWorkflow: { workflowId: 'workflow-origin' },
35+
...overrides,
36+
} as ExecutorDelegationOrigin
37+
}
38+
39+
describe('bindExecutorManagedOAuthDelegation', () => {
40+
beforeEach(() => {
41+
vi.clearAllMocks()
42+
mockBindInternalExecutorDelegation.mockImplementation(async (claims, options) => ({
43+
kind: 'delegated',
44+
serviceId: 'executor',
45+
subjectUserId: claims.subjectUserId,
46+
workspaceId: 'workspace-canonical',
47+
delegationId: claims.delegationId,
48+
audience: options.audience,
49+
resourceScope: options.resourceScope,
50+
}))
51+
})
52+
53+
it('requires current workflow authority before binding', async () => {
54+
await expect(
55+
bindExecutorManagedOAuthDelegation(delegationOrigin({ currentWorkflow: undefined }), 'cred-1')
56+
).rejects.toThrow('Managed credential delegation is missing current workflow authority')
57+
expect(mockBindInternalExecutorDelegation).not.toHaveBeenCalled()
58+
})
59+
60+
it('binds the origin to the managed-OAuth audience scoped to one credential', async () => {
61+
const principal = await bindExecutorManagedOAuthDelegation(delegationOrigin(), 'cred-1')
62+
63+
expect(mockBindInternalExecutorDelegation).toHaveBeenCalledWith(
64+
expect.objectContaining({
65+
serviceId: 'executor',
66+
subjectUserId: 'user-origin',
67+
workflowId: 'workflow-origin',
68+
executionId: 'execution-origin',
69+
currentWorkflow: { workflowId: 'workflow-origin' },
70+
}),
71+
expect.objectContaining({
72+
audience: 'sim:managed-oauth-credentials',
73+
resourceScope: { credentialId: 'cred-1' },
74+
})
75+
)
76+
expect(principal).toMatchObject({
77+
audience: 'sim:managed-oauth-credentials',
78+
resourceScope: { credentialId: 'cred-1' },
79+
})
80+
})
81+
82+
it('wraps binding rejections into the managed-OAuth delegation error', async () => {
83+
mockBindInternalExecutorDelegation.mockRejectedValue(
84+
new InvalidInternalDelegationBindingError('stale workflow context')
85+
)
86+
87+
await expect(
88+
bindExecutorManagedOAuthDelegation(delegationOrigin(), 'cred-1')
89+
).rejects.toBeInstanceOf(InvalidManagedOAuthDelegationError)
90+
})
91+
92+
it('rethrows unexpected binding failures unchanged', async () => {
93+
mockBindInternalExecutorDelegation.mockRejectedValue(new Error('db unavailable'))
94+
95+
await expect(bindExecutorManagedOAuthDelegation(delegationOrigin(), 'cred-1')).rejects.toThrow(
96+
'db unavailable'
97+
)
98+
})
99+
})

‎apps/sim/lib/credentials/application/managed-oauth-delegation.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
InvalidInternalDelegationBindingError,
99
} from '@/lib/auth/internal-delegation'
1010
import { MANAGED_OAUTH_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization'
11+
import { createExecutorPrincipalFromDelegationOrigin } from '@/lib/internal/principals/executor'
12+
import type { ExecutorDelegationOrigin } from '@/executor/types'
1113

1214
export class InvalidManagedOAuthDelegationError extends Error {
1315
constructor() {
@@ -39,3 +41,32 @@ export async function authenticateManagedOAuthDelegation(
3941
throw error
4042
}
4143
}
44+
45+
/**
46+
* In-process sibling of {@link authenticateManagedOAuthDelegation}: binds the
47+
* executor's own delegation origin to one managed credential without minting and
48+
* re-verifying a delegation JWT. The binding still re-validates the workflow and
49+
* deployment context, so trust matches the wire path minus the signature check,
50+
* which proves nothing when the executor is the caller.
51+
*/
52+
export async function bindExecutorManagedOAuthDelegation(
53+
origin: ExecutorDelegationOrigin,
54+
credentialId: string
55+
): Promise<WorkflowExecutionDelegatedPrincipal> {
56+
if (!origin.currentWorkflow) {
57+
throw new Error('Managed credential delegation is missing current workflow authority')
58+
}
59+
60+
try {
61+
return await createExecutorPrincipalFromDelegationOrigin(
62+
origin,
63+
MANAGED_OAUTH_DELEGATION_AUDIENCE,
64+
{ credentialId }
65+
)
66+
} catch (error) {
67+
if (error instanceof InvalidInternalDelegationBindingError) {
68+
throw new InvalidManagedOAuthDelegationError()
69+
}
70+
throw error
71+
}
72+
}

‎apps/sim/lib/internal/principals/executor.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,13 @@ export function resolveExecutorOriginSubject(origin: ExecutorDelegationOrigin):
2929
return subjectUserId
3030
}
3131

32-
async function bindExecutorPrincipal(
32+
/**
33+
* Binds an executor delegation origin to a delegated principal in-process,
34+
* without minting and re-verifying a delegation JWT. The underlying binding
35+
* still re-validates the workflow and deployment context, so trust matches the
36+
* wire path minus the signature check, which proves nothing in-process.
37+
*/
38+
export async function createExecutorPrincipalFromDelegationOrigin(
3339
origin: ExecutorDelegationOrigin,
3440
audience: string,
3541
resourceScope?: DelegatedPrincipal['resourceScope'],
@@ -74,5 +80,11 @@ export async function createExecutorPrincipalFromExecutionContext({
7480
}: CreateExecutorPrincipalFromExecutionContextInput) {
7581
const origin = context.executorDelegationOrigin
7682
if (!origin) throw new ExecutorDelegationOriginRequiredError()
77-
return bindExecutorPrincipal(origin, audience, resourceScope, expiresAt, context.userId)
83+
return createExecutorPrincipalFromDelegationOrigin(
84+
origin,
85+
audience,
86+
resourceScope,
87+
expiresAt,
88+
context.userId
89+
)
7890
}

0 commit comments

Comments
 (0)