diff --git a/.claude/rules/sim-architecture.md b/.claude/rules/sim-architecture.md index a8b25498eae..d950851a3f1 100644 --- a/.claude/rules/sim-architecture.md +++ b/.claude/rules/sim-architecture.md @@ -62,21 +62,25 @@ Every export of a `'use client'` module becomes a *client reference* on the serv Server code runs in two runtimes with **different environments**. The app container loads the full env from `SIM_ENV_SECRET_ID` (Secrets Manager). Trigger.dev workers — which execute workflows, so every block handler and every tool call — get their env from the Trigger.dev -dashboard, and `trigger.config.ts` syncs only `DB_APP_NAME`. The repo cannot see what the -dashboard holds. +dashboard; `trigger.config.ts` additionally syncs `DB_APP_NAME`, `TRIGGER_DEV_ENABLED`, and the +`FUNCTION_EXECUTION_ENV` vars. The repo cannot see what the dashboard holds. So before replacing a worker's HTTP call to our own API with an in-process call, ask what env that work reads *on the app side*. Anything gated by a `require*Capability` helper is the sharp case: those **throw** when the variable is absent (`requireOAuthClientCapability` → `EnvCapabilityConfigurationError`), and the throw may be caught and reported as something -unrelated. OAuth token refresh is the known example — moving it into the worker turns every -expired credential into `Failed to refresh access token`, while a still-valid token hides the -bug entirely, so it surfaces hours later and only for whoever's token lapsed first. +unrelated — an in-worker OAuth refresh missing a provider's client pair reports every expired +credential as `Failed to refresh access token`, while a still-valid token hides the bug until it +lapses. The required step before such a conversion is verifying the dashboard env holds every +variable the moved code reads (for OAuth refresh: the `OAUTH_CLIENT_CAPABILITIES` key pairs in +`packages/deployment-config/src/env-capabilities.ts`). An in-process conversion is safe when the same work already runs in that runtime (the agent block has always called `executeProviderRequest` in-process, so router and evaluator joining it -is proven), or when the caller and the callee are both the app (a route calling a lib module, an -RSC prefetch reading the data layer). It is not safe on reasoning alone. +is proven; connector sync refreshing OAuth tokens in-worker is what proved credential-token +resolution could move in-process), or when the caller and the callee are both the app (a route +calling a lib module, an RSC prefetch reading the data layer). It is not safe on reasoning +alone — verify the env, then convert. ## Feature Organization diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index d695df99fa0..03b5c1a9f69 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -1,8 +1,4 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { - resolvePrincipalSubject, - type WorkflowExecutionDelegatedPrincipal, -} from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' @@ -14,20 +10,15 @@ import { import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { authenticateManagedOAuthDelegation } from '@/lib/credentials/application/managed-oauth-delegation' +import { getCredential, getOAuthToken } from '@/lib/oauth/credential-service' import { - authenticateManagedOAuthDelegation, - InvalidManagedOAuthDelegationError, -} from '@/lib/credentials/application/managed-oauth-delegation' -import { resolveManagedOAuthCredentialToken } from '@/lib/credentials/application/resolve-managed-oauth-token' -import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth' -import { getCredential, getOAuthToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service' -import { completeOAuthCredentialToken, resolveCredentialToken } from '@/lib/oauth/token-resolution' -import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' + completeOAuthCredentialToken, + resolveCredentialAccessToken, +} from '@/lib/oauth/token-resolution' import { captureServerEvent } from '@/lib/posthog/server' -import { getToolMetadata } from '@/tools/metadata' export const dynamic = 'force-dynamic' @@ -130,142 +121,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } - const resolved = credentialId ? await resolveOAuthAccountId(credentialId) : null - if (resolved?.credentialType === 'managed_oauth' && resolved.credentialId) { - const managedOAuthDelegation = parsed.data.headers?.[MANAGED_OAUTH_DELEGATION_HEADER] - if (!managedOAuthDelegation) { - return NextResponse.json( - { - code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED', - error: 'Managed credentials can only be used by an authenticated workflow execution', - }, - { status: 403 } - ) - } - - let managedOAuthPrincipal: WorkflowExecutionDelegatedPrincipal - try { - managedOAuthPrincipal = await authenticateManagedOAuthDelegation( - managedOAuthDelegation, - resolved.credentialId - ) - } catch (error) { - if (!(error instanceof InvalidManagedOAuthDelegationError)) throw error - return NextResponse.json( - { - code: 'MANAGED_CREDENTIAL_DELEGATION_INVALID', - error: error.message, - }, - { status: 401 } - ) - } - if (!toolId) { - return NextResponse.json( - { - code: 'MANAGED_CREDENTIAL_TOOL_REQUIRED', - error: 'A tool ID is required to use a managed credential', - }, - { status: 400 } - ) - } - - const toolMetadata = getToolMetadata(toolId) - if (!toolMetadata?.oauth?.required) { - logger.error(`[${requestId}] Tool is not configured for managed OAuth`, { toolId }) - return NextResponse.json( - { - code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED', - error: 'This tool is not configured to use managed credentials', - }, - { status: 500 } - ) - } - const requiredScopes = - toolMetadata.oauth.requiredScopes ?? - getCanonicalScopesForProvider(toolMetadata.oauth.provider) - if (requiredScopes.length === 0) { - logger.error(`[${requestId}] Tool has no trusted OAuth scope policy`, { - toolId, - providerId: toolMetadata.oauth.provider, - }) - return NextResponse.json( - { - code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED', - error: 'This tool is not configured to use managed credentials', - }, - { status: 500 } - ) - } - - try { - const result = await resolveManagedOAuthCredentialToken.execute({ - principal: managedOAuthPrincipal, - input: { - credentialId: resolved.credentialId, - expectedProviderId: toolMetadata.oauth.provider, - requiredScopes, - toolId, - }, - request, - }) - - const managedOAuthSubject = resolvePrincipalSubject(managedOAuthPrincipal) - if (managedOAuthSubject?.kind === 'sim_user') { - captureServerEvent( - managedOAuthSubject.userId, - 'credential_used', - { - credential_type: 'managed_oauth', - provider_id: toolMetadata.oauth.provider, - workspace_id: managedOAuthPrincipal.workspaceId, - }, - { groups: { workspace: managedOAuthPrincipal.workspaceId } } - ) - } - - return NextResponse.json( - { - accessToken: result.accessToken, - ...(result.idToken ? { idToken: result.idToken } : {}), - }, - { status: 200 } - ) - } catch (error) { - if (error instanceof ManagedOAuthCredentialError) { - logger.warn(`[${requestId}] Managed OAuth credential rejected`, { - credentialId: resolved.credentialId, - code: error.code, - }) - return NextResponse.json( - { code: error.code, error: error.message }, - { status: error.statusCode } - ) - } - - const orchestrationError = asOrchestrationError(error) - if (orchestrationError) { - return NextResponse.json( - { - code: 'MANAGED_CREDENTIAL_UNAUTHORIZED', - error: orchestrationError.message, - }, - { status: statusForOrchestrationError(orchestrationError.code) } - ) - } - throw error - } - } - - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - const result = await resolveCredentialToken(auth, { + const managedOAuthDelegation = parsed.data.headers?.[MANAGED_OAUTH_DELEGATION_HEADER] + const result = await resolveCredentialAccessToken({ requestId, credentialId, workflowId: workflowId ?? undefined, + toolId, scopes, impersonateEmail, callerUserId, auditRequest: request, - resolvedCredential: resolved, + authenticate: () => checkSessionOrInternalAuth(request, { requireWorkflowId: false }), + resolveManagedPrincipal: managedOAuthDelegation + ? (managedCredentialId: string) => + authenticateManagedOAuthDelegation(managedOAuthDelegation, managedCredentialId) + : undefined, }) if (!result.ok) { diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts index ca9958d802a..22a337b3812 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts @@ -16,7 +16,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock) vi.mock('@/executor/utils/credential-token', () => ({ - fetchCredentialAccessToken: vi.fn().mockResolvedValue('mock-access-token'), + resolveExecutorCredentialToken: vi.fn().mockResolvedValue({ accessToken: 'mock-access-token' }), })) vi.mock('@/lib/credentials/access', () => ({ diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index dbbdf90f2b1..87049e09bf9 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -22,7 +22,7 @@ vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock) vi.mock('@/lib/core/security/encryption', () => encryptionMock) vi.mock('@/executor/utils/credential-token', () => ({ - fetchCredentialAccessToken: vi.fn().mockResolvedValue('mock-access-token'), + resolveExecutorCredentialToken: vi.fn().mockResolvedValue({ accessToken: 'mock-access-token' }), })) vi.mock('@/lib/credentials/access', () => ({ diff --git a/apps/sim/executor/utils/credential-token.test.ts b/apps/sim/executor/utils/credential-token.test.ts new file mode 100644 index 00000000000..c4a7d64b160 --- /dev/null +++ b/apps/sim/executor/utils/credential-token.test.ts @@ -0,0 +1,146 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutorDelegationOrigin } from '@/executor/types' + +const { mockBindExecutorManagedOAuthDelegation, mockResolveCredentialAccessToken } = vi.hoisted( + () => ({ + mockBindExecutorManagedOAuthDelegation: vi.fn(), + mockResolveCredentialAccessToken: vi.fn(), + }) +) + +vi.mock('@/lib/oauth/token-resolution', () => ({ + resolveCredentialAccessToken: mockResolveCredentialAccessToken, +})) + +vi.mock('@/lib/credentials/application/managed-oauth-delegation', () => ({ + bindExecutorManagedOAuthDelegation: mockBindExecutorManagedOAuthDelegation, +})) + +import { resolveExecutorCredentialToken } from '@/executor/utils/credential-token' + +const ORIGIN: ExecutorDelegationOrigin = { + subjectUserId: 'user-1', + workflowId: 'wf-1', + executionId: 'exec-1', + currentWorkflow: { workflowId: 'wf-1' }, +} as ExecutorDelegationOrigin + +describe('resolveExecutorCredentialToken', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveCredentialAccessToken.mockResolvedValue({ + ok: true, + token: { accessToken: 'fresh' }, + }) + }) + + it('dispatches with an internal-JWT auth result for the executing user', async () => { + await resolveExecutorCredentialToken({ + requestId: 'req-1', + credentialId: 'cred-1', + userId: 'user-1', + workflowId: 'wf-1', + toolId: 'gmail_read', + }) + + const input = mockResolveCredentialAccessToken.mock.calls[0][0] + expect(input).toMatchObject({ + requestId: 'req-1', + credentialId: 'cred-1', + workflowId: 'wf-1', + toolId: 'gmail_read', + }) + await expect(input.authenticate()).toEqual({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + expect(input.resolveManagedPrincipal).toBeUndefined() + }) + + it('asserts the caller only when the run enforces credential access', async () => { + await resolveExecutorCredentialToken({ + requestId: 'req-1', + credentialId: 'cred-1', + userId: 'user-1', + }) + expect(mockResolveCredentialAccessToken.mock.calls[0][0].callerUserId).toBeUndefined() + + await resolveExecutorCredentialToken({ + requestId: 'req-2', + credentialId: 'cred-1', + userId: 'user-1', + enforceCredentialAccess: true, + }) + expect(mockResolveCredentialAccessToken.mock.calls[1][0].callerUserId).toBe('user-1') + }) + + it('wires the managed delegation binder only when the run carries an origin', async () => { + mockBindExecutorManagedOAuthDelegation.mockResolvedValue({ kind: 'delegated' }) + + await resolveExecutorCredentialToken({ + requestId: 'req-1', + credentialId: 'cred-1', + userId: 'user-1', + executorDelegationOrigin: ORIGIN, + }) + + const input = mockResolveCredentialAccessToken.mock.calls[0][0] + expect(input.resolveManagedPrincipal).toBeTypeOf('function') + await input.resolveManagedPrincipal('managed-1') + expect(mockBindExecutorManagedOAuthDelegation).toHaveBeenCalledWith(ORIGIN, 'managed-1') + }) + + it('fails before dispatch when the origin lacks current workflow authority', async () => { + await expect( + resolveExecutorCredentialToken({ + requestId: 'req-1', + credentialId: 'cred-1', + userId: 'user-1', + executorDelegationOrigin: { ...ORIGIN, currentWorkflow: undefined }, + }) + ).rejects.toThrow('Managed credential delegation is missing current workflow authority') + expect(mockResolveCredentialAccessToken).not.toHaveBeenCalled() + }) + + it('throws the executeTool error contract with the tool label on failure', async () => { + mockResolveCredentialAccessToken.mockResolvedValue({ + ok: false, + status: 401, + error: 'Failed to refresh access token', + }) + + await expect( + resolveExecutorCredentialToken({ + requestId: 'req-1', + credentialId: 'cred-1', + userId: 'user-1', + toolLabel: 'Gmail Read', + }) + ).rejects.toThrow('Failed to obtain credential for Gmail Read: Failed to refresh access token') + }) + + it('returns the full token payload untouched', async () => { + const token = { + accessToken: 'fresh', + idToken: 'id-1', + instanceUrl: 'https://contoso.crm.dynamics.com', + apiDomain: 'desk.zoho.com', + cloudId: 'cloud-1', + domain: 'example.atlassian.net', + authStyle: 'x-api-token', + } + mockResolveCredentialAccessToken.mockResolvedValue({ ok: true, token }) + + await expect( + resolveExecutorCredentialToken({ + requestId: 'req-1', + credentialId: 'cred-1', + userId: 'user-1', + }) + ).resolves.toEqual(token) + }) +}) diff --git a/apps/sim/executor/utils/credential-token.ts b/apps/sim/executor/utils/credential-token.ts index db01918fd62..6f02767477d 100644 --- a/apps/sim/executor/utils/credential-token.ts +++ b/apps/sim/executor/utils/credential-token.ts @@ -1,67 +1,76 @@ import { createLogger } from '@sim/logger' -import { generateInternalToken } from '@/lib/auth/internal' -import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { AuthType } from '@/lib/auth/hybrid' +import { bindExecutorManagedOAuthDelegation } from '@/lib/credentials/application/managed-oauth-delegation' +import { + type CredentialTokenPayload, + resolveCredentialAccessToken, +} from '@/lib/oauth/token-resolution' +import type { ExecutorDelegationOrigin } from '@/executor/types' const logger = createLogger('ExecutorCredentialToken') -/** - * Fetches a credential's access token from the app rather than resolving it here. - * - * Refreshing an OAuth token needs the provider's client id and secret, read through - * `requireOAuthClientCapability`, which THROWS when they are absent. Only the app - * container loads those (from `SIM_ENV_SECRET_ID`); workflow execution runs in a - * Trigger.dev worker whose environment does not carry them. Resolving in-process there - * turns every credential whose access token has expired into a refresh failure, and a - * still-valid token hides it until the token lapses. - * - * See `.claude/rules/sim-architecture.md`, "The app/worker runtime boundary". - * - * The route authorizes the credential itself, so this never widens access. - */ -export async function fetchCredentialAccessToken(params: { +export interface ResolveExecutorCredentialTokenParams { requestId: string credentialId: string - userId: string + userId?: string workflowId?: string -}): Promise { - const { requestId, credentialId, userId, workflowId } = params + /** Tool consuming the token; required by the managed-OAuth scope policy. */ + toolId?: string + /** Display label for the thrown failure ("Failed to obtain credential for X: ..."). */ + toolLabel?: string + scopes?: string[] + impersonateEmail?: string + /** Asserts the acting user alongside the credential lookup, mirroring the HTTP surface. */ + enforceCredentialAccess?: boolean + /** Proves managed-credential delegations in-process when the run carries one. */ + executorDelegationOrigin?: ExecutorDelegationOrigin +} - const url = new URL('/api/auth/oauth/token', getInternalApiBaseUrl()) - if (workflowId) url.searchParams.set('workflowId', workflowId) +/** + * Resolves a credential's access token in-process for server-side workflow + * execution, through the same authorized application dispatch as + * `POST /api/auth/oauth/token` (`resolveCredentialAccessToken`). Both runtimes + * hold the OAuth client config the refresh branch needs, so authorization, + * refresh, and audit run identically to the route. + */ +export async function resolveExecutorCredentialToken( + params: ResolveExecutorCredentialTokenParams +): Promise { + const { requestId, credentialId, userId, workflowId, toolId, executorDelegationOrigin } = params - const headers: Record = { 'Content-Type': 'application/json' } - try { - headers.Authorization = `Bearer ${await generateInternalToken(userId)}` - } catch (_e) { - // Swallow mint errors; the request then fails authentication and reports upstream. + if (executorDelegationOrigin && !executorDelegationOrigin.currentWorkflow) { + throw new Error('Managed credential delegation is missing current workflow authority') } - // boundary-raw-fetch: same-origin token route, authenticated by the internal JWT minted above - const response = await fetch(url.toString(), { - method: 'POST', - headers, - body: JSON.stringify({ credentialId, ...(workflowId ? { workflowId } : {}) }), + const result = await resolveCredentialAccessToken({ + requestId, + credentialId, + workflowId, + toolId, + scopes: params.scopes, + impersonateEmail: params.impersonateEmail, + callerUserId: userId && params.enforceCredentialAccess ? userId : undefined, + authenticate: () => ({ + success: true, + userId, + authType: AuthType.INTERNAL_JWT, + }), + resolveManagedPrincipal: executorDelegationOrigin + ? (managedCredentialId: string) => + bindExecutorManagedOAuthDelegation(executorDelegationOrigin, managedCredentialId) + : undefined, }) - if (!response.ok) { - const errorText = await response.text() - logger.error(`[${requestId}] Credential token request failed`, { - status: response.status, + if (!result.ok) { + logger.error(`[${requestId}] Credential token resolution failed`, { + status: result.status, credentialId, + code: result.code, }) - let message = errorText - try { - const parsed = JSON.parse(errorText) - if (parsed.error) message = parsed.error - } catch { - // Use raw text - } - throw new Error(message) + throw new Error( + `Failed to obtain credential for ${params.toolLabel ?? credentialId}: ${result.error}` + ) } - const { accessToken } = (await response.json()) as { accessToken?: string } - if (!accessToken) { - throw new Error('Credential token response carried no access token') - } - return accessToken + return result.token } diff --git a/apps/sim/executor/utils/http.test.ts b/apps/sim/executor/utils/http.test.ts index 811a77e60f3..52796756c5e 100644 --- a/apps/sim/executor/utils/http.test.ts +++ b/apps/sim/executor/utils/http.test.ts @@ -4,21 +4,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - generateInternalDelegationToken: vi.fn(), generateInternalToken: vi.fn(), })) vi.mock('@/lib/auth/internal', () => ({ - generateInternalDelegationToken: mocks.generateInternalDelegationToken, generateInternalToken: mocks.generateInternalToken, })) -import { buildAuthHeaders, buildExecutorDelegationHeaders } from '@/executor/utils/http' +import { buildAuthHeaders } from '@/executor/utils/http' describe('executor HTTP authentication headers', () => { beforeEach(() => { vi.clearAllMocks() - mocks.generateInternalDelegationToken.mockResolvedValue('delegation-token') mocks.generateInternalToken.mockResolvedValue('legacy-token') }) @@ -26,44 +23,11 @@ describe('executor HTTP authentication headers', () => { vi.unstubAllGlobals() }) - it('issues a workflow-scoped executor delegation', async () => { - await expect( - buildExecutorDelegationHeaders({ - subjectUserId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - }) - ).resolves.toEqual({ - 'Content-Type': 'application/json', - Authorization: 'Bearer delegation-token', - }) - - expect(mocks.generateInternalDelegationToken).toHaveBeenCalledWith({ - subjectUserId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - }) - expect(mocks.generateInternalToken).not.toHaveBeenCalled() - }) - - it('fails instead of issuing trusted delegation headers in a browser', async () => { - vi.stubGlobal('window', {}) - - await expect( - buildExecutorDelegationHeaders({ - subjectUserId: 'user-1', - workflowId: 'workflow-1', - }) - ).rejects.toThrow('Executor delegation headers can only be created on the server') - expect(mocks.generateInternalDelegationToken).not.toHaveBeenCalled() - }) - - it('keeps the legacy helper separate during endpoint migration', async () => { + it('mints an internal token for server-side calls', async () => { await expect(buildAuthHeaders('user-1')).resolves.toEqual({ 'Content-Type': 'application/json', Authorization: 'Bearer legacy-token', }) expect(mocks.generateInternalToken).toHaveBeenCalledWith('user-1') - expect(mocks.generateInternalDelegationToken).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/executor/utils/http.ts b/apps/sim/executor/utils/http.ts index 0d74d422268..57ea632a41b 100644 --- a/apps/sim/executor/utils/http.ts +++ b/apps/sim/executor/utils/http.ts @@ -1,12 +1,7 @@ -import { - type GenerateInternalDelegationTokenInput, - generateInternalDelegationToken, - generateInternalToken, -} from '@/lib/auth/internal' +import { generateInternalToken } from '@/lib/auth/internal' import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { HTTP } from '@/executor/constants' -/** @deprecated Use `buildExecutorDelegationHeaders` for protected application routes. */ export async function buildAuthHeaders(userId?: string): Promise> { const headers: Record = { 'Content-Type': HTTP.CONTENT_TYPE.JSON, @@ -20,21 +15,6 @@ export async function buildAuthHeaders(userId?: string): Promise> { - if (typeof window !== 'undefined') { - throw new Error('Executor delegation headers can only be created on the server') - } - - const token = await generateInternalDelegationToken(input) - return { - 'Content-Type': HTTP.CONTENT_TYPE.JSON, - Authorization: `Bearer ${token}`, - } -} - export function buildAPIUrl(path: string, params?: Record): URL { const baseUrl = path.startsWith('/api/') ? getInternalApiBaseUrl() : getBaseUrl() const url = new URL(path, baseUrl) diff --git a/apps/sim/executor/utils/vertex-credential.test.ts b/apps/sim/executor/utils/vertex-credential.test.ts index cea1632a235..1f80513604a 100644 --- a/apps/sim/executor/utils/vertex-credential.test.ts +++ b/apps/sim/executor/utils/vertex-credential.test.ts @@ -7,12 +7,12 @@ const { mockGetCredentialActorContext, mockGetServiceAccountToken, mockRefreshTokenIfNeeded, - mockFetchCredentialAccessToken, + mockResolveExecutorCredentialToken, } = vi.hoisted(() => ({ mockGetCredentialActorContext: vi.fn(), mockGetServiceAccountToken: vi.fn(), mockRefreshTokenIfNeeded: vi.fn(), - mockFetchCredentialAccessToken: vi.fn(), + mockResolveExecutorCredentialToken: vi.fn(), })) vi.mock('@/lib/credentials/access', () => ({ @@ -25,7 +25,7 @@ vi.mock('@/lib/oauth/credential-service', () => ({ refreshTokenIfNeeded: mockRefreshTokenIfNeeded, })) vi.mock('@/executor/utils/credential-token', () => ({ - fetchCredentialAccessToken: mockFetchCredentialAccessToken, + resolveExecutorCredentialToken: mockResolveExecutorCredentialToken, })) import { resolveVertexCredential } from '@/executor/utils/vertex-credential' @@ -104,10 +104,6 @@ describe('resolveVertexCredential workspace binding', () => { }) }) -/** - * This resolver runs inside the Trigger.dev worker, whose environment carries no OAuth - * client config — an in-process refresh throws there once the stored token expires. - */ describe('resolveVertexCredential OAuth branch', () => { const oauthContext = { credential: { id: 'cred-o', workspaceId: 'workspace-a', type: 'oauth', accountId: 'acct-1' }, @@ -120,10 +116,10 @@ describe('resolveVertexCredential OAuth branch', () => { beforeEach(() => { vi.clearAllMocks() mockGetCredentialActorContext.mockResolvedValue(oauthContext) - mockFetchCredentialAccessToken.mockResolvedValue('oauth-access-token') + mockResolveExecutorCredentialToken.mockResolvedValue({ accessToken: 'oauth-access-token' }) }) - it('fetches the token from the app instead of refreshing in-process', async () => { + it('resolves the token through the shared in-process resolver', async () => { await expect( resolveVertexCredential({ credentialId: 'cred-o', @@ -134,7 +130,7 @@ describe('resolveVertexCredential OAuth branch', () => { ).resolves.toBe('oauth-access-token') expect(mockRefreshTokenIfNeeded).not.toHaveBeenCalled() - expect(mockFetchCredentialAccessToken).toHaveBeenCalledWith( + expect(mockResolveExecutorCredentialToken).toHaveBeenCalledWith( expect.objectContaining({ credentialId: 'cred-o', userId: 'user-1', workflowId: 'wf-1' }) ) }) @@ -153,6 +149,6 @@ describe('resolveVertexCredential OAuth branch', () => { }) ).rejects.toThrow() - expect(mockFetchCredentialAccessToken).not.toHaveBeenCalled() + expect(mockResolveExecutorCredentialToken).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/executor/utils/vertex-credential.ts b/apps/sim/executor/utils/vertex-credential.ts index 5f4a2b17d13..c05c9f943a8 100644 --- a/apps/sim/executor/utils/vertex-credential.ts +++ b/apps/sim/executor/utils/vertex-credential.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' import { getServiceAccountToken } from '@/lib/oauth/credential-service' -import { fetchCredentialAccessToken } from '@/executor/utils/credential-token' +import { resolveExecutorCredentialToken } from '@/executor/utils/credential-token' const logger = createLogger('VertexCredential') @@ -65,17 +65,12 @@ export async function resolveVertexCredential({ throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`) } - /** - * Fetched from the app rather than refreshed here: this runs inside the Trigger.dev - * worker, whose environment carries no OAuth client config, so an in-process refresh - * throws once the stored access token expires. The service-account branch above needs - * no such config and stays in-process. - */ - const accessToken = await fetchCredentialAccessToken({ + const { accessToken } = await resolveExecutorCredentialToken({ requestId, credentialId, userId: actingUserId, workflowId, + toolLabel: 'Vertex AI', }) if (!accessToken) { diff --git a/apps/sim/lib/credentials/application/managed-oauth-delegation.test.ts b/apps/sim/lib/credentials/application/managed-oauth-delegation.test.ts new file mode 100644 index 00000000000..c6d950036b0 --- /dev/null +++ b/apps/sim/lib/credentials/application/managed-oauth-delegation.test.ts @@ -0,0 +1,99 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutorDelegationOrigin } from '@/executor/types' + +const { mockBindInternalExecutorDelegation } = vi.hoisted(() => ({ + mockBindInternalExecutorDelegation: vi.fn(), +})) + +vi.mock('@/lib/auth/internal-delegation', () => ({ + bindInternalExecutorDelegation: mockBindInternalExecutorDelegation, + InvalidInternalDelegationBindingError: class InvalidInternalDelegationBindingError extends Error {}, +})) + +vi.mock('@/lib/auth/internal', () => ({ + InvalidInternalDelegationTokenError: class InvalidInternalDelegationTokenError extends Error {}, + verifyInternalDelegationToken: vi.fn(), +})) + +import { InvalidInternalDelegationBindingError } from '@/lib/auth/internal-delegation' +import { + bindExecutorManagedOAuthDelegation, + InvalidManagedOAuthDelegationError, +} from '@/lib/credentials/application/managed-oauth-delegation' + +function delegationOrigin( + overrides: Partial = {} +): ExecutorDelegationOrigin { + return { + subjectUserId: 'user-origin', + workflowId: 'workflow-origin', + executionId: 'execution-origin', + currentWorkflow: { workflowId: 'workflow-origin' }, + ...overrides, + } as ExecutorDelegationOrigin +} + +describe('bindExecutorManagedOAuthDelegation', () => { + beforeEach(() => { + vi.clearAllMocks() + mockBindInternalExecutorDelegation.mockImplementation(async (claims, options) => ({ + kind: 'delegated', + serviceId: 'executor', + subjectUserId: claims.subjectUserId, + workspaceId: 'workspace-canonical', + delegationId: claims.delegationId, + audience: options.audience, + resourceScope: options.resourceScope, + })) + }) + + it('requires current workflow authority before binding', async () => { + await expect( + bindExecutorManagedOAuthDelegation(delegationOrigin({ currentWorkflow: undefined }), 'cred-1') + ).rejects.toThrow('Managed credential delegation is missing current workflow authority') + expect(mockBindInternalExecutorDelegation).not.toHaveBeenCalled() + }) + + it('binds the origin to the managed-OAuth audience scoped to one credential', async () => { + const principal = await bindExecutorManagedOAuthDelegation(delegationOrigin(), 'cred-1') + + expect(mockBindInternalExecutorDelegation).toHaveBeenCalledWith( + expect.objectContaining({ + serviceId: 'executor', + subjectUserId: 'user-origin', + workflowId: 'workflow-origin', + executionId: 'execution-origin', + currentWorkflow: { workflowId: 'workflow-origin' }, + }), + expect.objectContaining({ + audience: 'sim:managed-oauth-credentials', + resourceScope: { credentialId: 'cred-1' }, + }) + ) + expect(principal).toMatchObject({ + audience: 'sim:managed-oauth-credentials', + resourceScope: { credentialId: 'cred-1' }, + }) + }) + + it('wraps binding rejections into the managed-OAuth delegation error', async () => { + mockBindInternalExecutorDelegation.mockRejectedValue( + new InvalidInternalDelegationBindingError('stale workflow context') + ) + + await expect( + bindExecutorManagedOAuthDelegation(delegationOrigin(), 'cred-1') + ).rejects.toBeInstanceOf(InvalidManagedOAuthDelegationError) + }) + + it('rethrows unexpected binding failures unchanged', async () => { + mockBindInternalExecutorDelegation.mockRejectedValue(new Error('db unavailable')) + + await expect(bindExecutorManagedOAuthDelegation(delegationOrigin(), 'cred-1')).rejects.toThrow( + 'db unavailable' + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/managed-oauth-delegation.ts b/apps/sim/lib/credentials/application/managed-oauth-delegation.ts index 95c85426c0d..d5c68994745 100644 --- a/apps/sim/lib/credentials/application/managed-oauth-delegation.ts +++ b/apps/sim/lib/credentials/application/managed-oauth-delegation.ts @@ -8,6 +8,8 @@ import { InvalidInternalDelegationBindingError, } from '@/lib/auth/internal-delegation' import { MANAGED_OAUTH_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' +import { createExecutorPrincipalFromDelegationOrigin } from '@/lib/internal/principals/executor' +import type { ExecutorDelegationOrigin } from '@/executor/types' export class InvalidManagedOAuthDelegationError extends Error { constructor() { @@ -39,3 +41,31 @@ export async function authenticateManagedOAuthDelegation( throw error } } + +/** + * In-process sibling of {@link authenticateManagedOAuthDelegation}: binds the + * executor's own delegation origin to one managed credential without minting and + * re-verifying a delegation JWT — see {@link createExecutorPrincipalFromDelegationOrigin} + * for why that loses nothing. + */ +export async function bindExecutorManagedOAuthDelegation( + origin: ExecutorDelegationOrigin, + credentialId: string +): Promise { + if (!origin.currentWorkflow) { + throw new Error('Managed credential delegation is missing current workflow authority') + } + + try { + return await createExecutorPrincipalFromDelegationOrigin( + origin, + MANAGED_OAUTH_DELEGATION_AUDIENCE, + { credentialId } + ) + } catch (error) { + if (error instanceof InvalidInternalDelegationBindingError) { + throw new InvalidManagedOAuthDelegationError() + } + throw error + } +} diff --git a/apps/sim/lib/internal/principals/executor.ts b/apps/sim/lib/internal/principals/executor.ts index 4aeeb57e5a8..1c40c232116 100644 --- a/apps/sim/lib/internal/principals/executor.ts +++ b/apps/sim/lib/internal/principals/executor.ts @@ -29,7 +29,13 @@ export function resolveExecutorOriginSubject(origin: ExecutorDelegationOrigin): return subjectUserId } -async function bindExecutorPrincipal( +/** + * Binds an executor delegation origin to a delegated principal in-process, + * without minting and re-verifying a delegation JWT. The underlying binding + * still re-validates the workflow and deployment context, so trust matches the + * wire path minus the signature check, which proves nothing in-process. + */ +export async function createExecutorPrincipalFromDelegationOrigin( origin: ExecutorDelegationOrigin, audience: string, resourceScope?: DelegatedPrincipal['resourceScope'], @@ -74,5 +80,11 @@ export async function createExecutorPrincipalFromExecutionContext({ }: CreateExecutorPrincipalFromExecutionContextInput) { const origin = context.executorDelegationOrigin if (!origin) throw new ExecutorDelegationOriginRequiredError() - return bindExecutorPrincipal(origin, audience, resourceScope, expiresAt, context.userId) + return createExecutorPrincipalFromDelegationOrigin( + origin, + audience, + resourceScope, + expiresAt, + context.userId + ) } diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index aff22174309..337b56aa435 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -64,7 +64,7 @@ vi.mock('@/lib/oauth/terminal-errors', () => ({ markCredentialDead: vi.fn(), })) -import { resolveCredentialAccessToken } from '@/lib/oauth/credential-service' +import { resolveCredentialTokenBundle } from '@/lib/oauth/credential-service' const RAW_CREDENTIAL_ID = 'credential-raw-secret-id' const RAW_ACCOUNT_ID = 'account-raw-secret-id' @@ -127,7 +127,7 @@ async function observeRefresh( ]) await expect( - resolveCredentialAccessToken( + resolveCredentialTokenBundle( RAW_CREDENTIAL_ID, RAW_USER_ID, 'selector-execution', @@ -149,7 +149,7 @@ async function observeRefresh( } } -describe('resolveCredentialAccessToken selector privacy', () => { +describe('resolveCredentialTokenBundle selector privacy', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index 847ee7f5111..0962cf8cb56 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -856,6 +856,7 @@ async function performCoalescedRefresh({ logger.error('Failed to refresh token', { ...logContext, errorCode: result.errorCode, + message: result.message, }) if (result.errorCode && isTerminalRefreshError(result.errorCode)) { // A refresh that lost a race with a concurrent connect fails with @@ -1027,7 +1028,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise * Pipedrive's `x-api-token`. OAuth credentials resolve with `accessToken` * only. */ -export async function resolveCredentialAccessToken( +export async function resolveCredentialTokenBundle( credentialId: string, userId: string, requestId: string, @@ -1130,7 +1131,7 @@ export async function resolveCredentialAccessToken( /** * Refreshes an OAuth token if needed based on credential information. * Also handles service account credentials by generating a JWT-based token. - * Thin string wrapper over {@link resolveCredentialAccessToken}. + * Thin string wrapper over {@link resolveCredentialTokenBundle}. * @param credentialId The ID of the credential to check and potentially refresh * @param userId The user ID who owns the credential (for security verification) * @param requestId Request ID for log correlation @@ -1145,7 +1146,7 @@ export async function refreshAccessTokenIfNeeded( impersonateEmail?: string, options?: CredentialTokenResolutionOptions ): Promise { - const result = await resolveCredentialAccessToken( + const result = await resolveCredentialTokenBundle( credentialId, userId, requestId, diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index 6374c9f711b..c8acc37edb8 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -5,14 +5,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockAuthorizeCredentialUseForAuth, + mockCaptureServerEvent, + mockExecuteManagedToken, mockGetCredential, + mockGetToolMetadata, mockRecordAudit, mockRefreshTokenIfNeeded, mockResolveOAuthAccountId, mockResolveServiceAccountToken, } = vi.hoisted(() => ({ mockAuthorizeCredentialUseForAuth: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockExecuteManagedToken: vi.fn(), mockGetCredential: vi.fn(), + mockGetToolMetadata: vi.fn(), mockRecordAudit: vi.fn(), mockRefreshTokenIfNeeded: vi.fn(), mockResolveOAuthAccountId: vi.fn(), @@ -37,18 +43,55 @@ vi.mock('@/lib/oauth/credential-service', () => ({ })) vi.mock('@/lib/posthog/server', () => ({ - captureServerEvent: vi.fn(), + captureServerEvent: mockCaptureServerEvent, })) +vi.mock('@/lib/credentials/application/managed-oauth-delegation', () => ({ + InvalidManagedOAuthDelegationError: class InvalidManagedOAuthDelegationError extends Error { + constructor() { + super('Managed credential execution requires valid workflow delegation') + this.name = 'InvalidManagedOAuthDelegationError' + } + }, + authenticateManagedOAuthDelegation: vi.fn(), +})) + +vi.mock('@/lib/credentials/application/resolve-managed-oauth-token', () => ({ + resolveManagedOAuthCredentialToken: { execute: mockExecuteManagedToken }, +})) + +vi.mock('@/lib/credentials/managed-oauth', () => ({ + ManagedOAuthCredentialError: class ManagedOAuthCredentialError extends Error { + constructor( + message: string, + readonly code: string, + readonly statusCode: number + ) { + super(message) + this.name = 'ManagedOAuthCredentialError' + } + }, +})) + +vi.mock('@/tools/metadata', () => ({ + getToolMetadata: mockGetToolMetadata, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getCanonicalScopesForProvider: vi.fn().mockReturnValue([]), +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { InvalidManagedOAuthDelegationError } from '@/lib/credentials/application/managed-oauth-delegation' +import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' -import { resolveCredentialToken } from '@/lib/oauth/token-resolution' +import { resolveCredentialAccessToken, resolveCredentialToken } from '@/lib/oauth/token-resolution' const INTERNAL_AUTH = { success: true, userId: 'user-1', authType: 'internal_jwt' } as const describe('resolveCredentialToken', () => { beforeEach(() => { vi.clearAllMocks() - mockResolveOAuthAccountId.mockResolvedValue(null) }) it('fails closed when the credential is not authorized', async () => { @@ -59,6 +102,7 @@ describe('resolveCredentialToken', () => { const result = await resolveCredentialToken(INTERNAL_AUTH, { requestId: 'req-1', + resolvedCredential: null, credentialId: 'cred-1', }) @@ -80,7 +124,7 @@ describe('resolveCredentialToken', () => { const result = await resolveCredentialToken( { success: true, authType: 'internal_jwt' }, - { requestId: 'req-1', credentialId: 'cred-1' } + { requestId: 'req-1', credentialId: 'cred-1', resolvedCredential: null } ) expect(result).toEqual({ ok: false, status: 403, error: 'Authentication required' }) @@ -103,6 +147,7 @@ describe('resolveCredentialToken', () => { const result = await resolveCredentialToken(INTERNAL_AUTH, { requestId: 'req-1', + resolvedCredential: null, credentialId: 'cred-1', workflowId: 'wf-1', }) @@ -133,6 +178,7 @@ describe('resolveCredentialToken', () => { const result = await resolveCredentialToken(INTERNAL_AUTH, { requestId: 'req-1', + resolvedCredential: null, credentialId: 'cred-1', }) @@ -157,6 +203,7 @@ describe('resolveCredentialToken', () => { await expect( resolveCredentialToken(INTERNAL_AUTH, { requestId: 'req-1', + resolvedCredential: null, credentialId: 'cred-1', }) ).resolves.toEqual({ @@ -187,6 +234,7 @@ describe('resolveCredentialToken', () => { await expect( resolveCredentialToken(INTERNAL_AUTH, { requestId: 'req-1', + resolvedCredential: null, credentialId: 'cred-1', }) ).resolves.toEqual({ @@ -207,6 +255,7 @@ describe('resolveCredentialToken', () => { const result = await resolveCredentialToken(INTERNAL_AUTH, { requestId: 'req-1', + resolvedCredential: null, credentialId: 'cred-1', }) @@ -215,19 +264,19 @@ describe('resolveCredentialToken', () => { }) it('authorizes service-account credentials before minting a token', async () => { - mockResolveOAuthAccountId.mockResolvedValue({ - credentialType: 'service_account', - credentialId: 'sa-1', - providerId: 'google', - workspaceId: 'ws-1', - accountId: '', - usedCredentialTable: true, - }) mockAuthorizeCredentialUseForAuth.mockResolvedValue({ ok: false, error: 'Unauthorized' }) const result = await resolveCredentialToken(INTERNAL_AUTH, { requestId: 'req-1', credentialId: 'cred-1', + resolvedCredential: { + credentialType: 'service_account', + credentialId: 'sa-1', + providerId: 'google', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + }, }) expect(result).toEqual({ ok: false, status: 403, error: 'Unauthorized' }) @@ -235,14 +284,6 @@ describe('resolveCredentialToken', () => { }) it('surfaces the classified service-account failure code', async () => { - mockResolveOAuthAccountId.mockResolvedValue({ - credentialType: 'service_account', - credentialId: 'sa-1', - providerId: 'atlassian', - workspaceId: 'ws-1', - accountId: '', - usedCredentialTable: true, - }) mockAuthorizeCredentialUseForAuth.mockResolvedValue({ ok: true, requesterUserId: 'user-1' }) mockResolveServiceAccountToken.mockRejectedValue( new TokenServiceAccountValidationError('invalid_credentials', 401) @@ -251,6 +292,14 @@ describe('resolveCredentialToken', () => { const result = await resolveCredentialToken(INTERNAL_AUTH, { requestId: 'req-1', credentialId: 'cred-1', + resolvedCredential: { + credentialType: 'service_account', + credentialId: 'sa-1', + providerId: 'atlassian', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, + }, }) expect(result).toEqual({ @@ -264,6 +313,7 @@ describe('resolveCredentialToken', () => { it('rejects a malformed impersonation subject before touching the credential', async () => { const result = await resolveCredentialToken(INTERNAL_AUTH, { requestId: 'req-1', + resolvedCredential: null, credentialId: 'cred-1', impersonateEmail: 'not-an-email', }) @@ -272,3 +322,293 @@ describe('resolveCredentialToken', () => { expect(mockAuthorizeCredentialUseForAuth).not.toHaveBeenCalled() }) }) + +const MANAGED_RESOLVED = { + credentialType: 'managed_oauth', + credentialId: 'managed-1', + providerId: 'google', + workspaceId: 'ws-1', + accountId: '', + usedCredentialTable: true, +} as const + +const EXECUTOR_PRINCIPAL = { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'ws-1', +} as never + +describe('resolveCredentialAccessToken', () => { + const authenticate = vi.fn() + const resolveManagedPrincipal = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockResolveOAuthAccountId.mockResolvedValue(null) + authenticate.mockResolvedValue(INTERNAL_AUTH) + resolveManagedPrincipal.mockResolvedValue(EXECUTOR_PRINCIPAL) + mockGetToolMetadata.mockReturnValue({ + oauth: { required: true, provider: 'google', requiredScopes: ['scope-a'] }, + }) + }) + + it('authenticates and delegates non-managed credentials without a second account lookup', async () => { + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: true, + requesterUserId: 'user-1', + credentialOwnerUserId: 'owner-1', + workspaceId: 'ws-1', + resolvedCredentialId: 'account-1', + }) + mockGetCredential.mockResolvedValue({ providerId: 'google' }) + mockRefreshTokenIfNeeded.mockResolvedValue({ accessToken: 'fresh', refreshed: false }) + + const result = await resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'cred-1', + workflowId: 'wf-1', + callerUserId: 'user-1', + authenticate, + resolveManagedPrincipal, + }) + + expect(result).toEqual({ + ok: true, + token: { accessToken: 'fresh', credentialType: 'oauth', idToken: undefined }, + }) + expect(authenticate).toHaveBeenCalledTimes(1) + expect(resolveManagedPrincipal).not.toHaveBeenCalled() + expect(mockResolveOAuthAccountId).toHaveBeenCalledTimes(1) + expect(mockAuthorizeCredentialUseForAuth).toHaveBeenCalledWith(INTERNAL_AUTH, { + credentialId: 'cred-1', + workflowId: 'wf-1', + callerUserId: 'user-1', + }) + }) + + it('treats an empty impersonation subject as absent', async () => { + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: true, + requesterUserId: 'user-1', + credentialOwnerUserId: 'owner-1', + workspaceId: 'ws-1', + resolvedCredentialId: 'account-1', + }) + mockGetCredential.mockResolvedValue({ providerId: 'google' }) + mockRefreshTokenIfNeeded.mockResolvedValue({ accessToken: 'fresh', refreshed: false }) + + const result = await resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'cred-1', + impersonateEmail: '', + authenticate, + }) + + expect(result).toEqual({ + ok: true, + token: { accessToken: 'fresh', credentialType: 'oauth', idToken: undefined }, + }) + }) + + it('rejects a managed credential when no delegation resolver is wired', async () => { + mockResolveOAuthAccountId.mockResolvedValue(MANAGED_RESOLVED) + + const result = await resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'cred-1', + toolId: 'gmail_send', + authenticate, + }) + + expect(result).toEqual({ + ok: false, + status: 403, + code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED', + error: 'Managed credentials can only be used by an authenticated workflow execution', + }) + expect(authenticate).not.toHaveBeenCalled() + }) + + it('maps an invalid delegation to 401 with its message', async () => { + mockResolveOAuthAccountId.mockResolvedValue(MANAGED_RESOLVED) + resolveManagedPrincipal.mockRejectedValue(new InvalidManagedOAuthDelegationError()) + + const result = await resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'cred-1', + toolId: 'gmail_send', + authenticate, + resolveManagedPrincipal, + }) + + expect(result).toEqual({ + ok: false, + status: 401, + code: 'MANAGED_CREDENTIAL_DELEGATION_INVALID', + error: 'Managed credential execution requires valid workflow delegation', + }) + expect(resolveManagedPrincipal).toHaveBeenCalledWith('managed-1') + }) + + it('rethrows unexpected delegation resolver failures', async () => { + mockResolveOAuthAccountId.mockResolvedValue(MANAGED_RESOLVED) + resolveManagedPrincipal.mockRejectedValue(new Error('db unavailable')) + + await expect( + resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'cred-1', + toolId: 'gmail_send', + authenticate, + resolveManagedPrincipal, + }) + ).rejects.toThrow('db unavailable') + }) + + it('requires a tool id for managed credentials', async () => { + mockResolveOAuthAccountId.mockResolvedValue(MANAGED_RESOLVED) + + const result = await resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'cred-1', + authenticate, + resolveManagedPrincipal, + }) + + expect(result).toEqual({ + ok: false, + status: 400, + code: 'MANAGED_CREDENTIAL_TOOL_REQUIRED', + error: 'A tool ID is required to use a managed credential', + }) + }) + + it('rejects tools without managed OAuth support', async () => { + mockResolveOAuthAccountId.mockResolvedValue(MANAGED_RESOLVED) + mockGetToolMetadata.mockReturnValue({ oauth: undefined }) + + const result = await resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'cred-1', + toolId: 'http_request', + authenticate, + resolveManagedPrincipal, + }) + + expect(result).toEqual({ + ok: false, + status: 500, + code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED', + error: 'This tool is not configured to use managed credentials', + }) + expect(mockExecuteManagedToken).not.toHaveBeenCalled() + }) + + it('rejects tools whose scope policy is empty', async () => { + mockResolveOAuthAccountId.mockResolvedValue(MANAGED_RESOLVED) + mockGetToolMetadata.mockReturnValue({ oauth: { required: true, provider: 'google' } }) + + const result = await resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'cred-1', + toolId: 'gmail_send', + authenticate, + resolveManagedPrincipal, + }) + + expect(result).toEqual({ + ok: false, + status: 500, + code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED', + error: 'This tool is not configured to use managed credentials', + }) + }) + + it('resolves a managed credential through the use case and records analytics', async () => { + mockResolveOAuthAccountId.mockResolvedValue(MANAGED_RESOLVED) + mockExecuteManagedToken.mockResolvedValue({ accessToken: 'managed-token', idToken: 'id-1' }) + const auditRequest = { headers: { get: () => null } } + + const result = await resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'cred-1', + toolId: 'gmail_send', + auditRequest, + authenticate, + resolveManagedPrincipal, + }) + + expect(result).toEqual({ + ok: true, + token: { accessToken: 'managed-token', idToken: 'id-1' }, + }) + expect(mockExecuteManagedToken).toHaveBeenCalledWith({ + principal: EXECUTOR_PRINCIPAL, + input: { + credentialId: 'managed-1', + expectedProviderId: 'google', + requiredScopes: ['scope-a'], + toolId: 'gmail_send', + }, + request: auditRequest, + }) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'credential_used', + expect.objectContaining({ credential_type: 'managed_oauth', provider_id: 'google' }), + { groups: { workspace: 'ws-1' } } + ) + expect(authenticate).not.toHaveBeenCalled() + }) + + it('projects managed credential rejections with their code and status', async () => { + mockResolveOAuthAccountId.mockResolvedValue(MANAGED_RESOLVED) + mockExecuteManagedToken.mockRejectedValue( + new ( + ManagedOAuthCredentialError as never as new ( + message: string, + code: string, + statusCode: number + ) => Error + )('Credential is disabled', 'MANAGED_CREDENTIAL_DISABLED', 403) + ) + + const result = await resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'cred-1', + toolId: 'gmail_send', + authenticate, + resolveManagedPrincipal, + }) + + expect(result).toEqual({ + ok: false, + status: 403, + code: 'MANAGED_CREDENTIAL_DISABLED', + error: 'Credential is disabled', + }) + }) + + it('projects orchestration failures as managed unauthorized', async () => { + mockResolveOAuthAccountId.mockResolvedValue(MANAGED_RESOLVED) + mockExecuteManagedToken.mockRejectedValue( + new OrchestrationError('not_found', 'Managed credential not found') + ) + + const result = await resolveCredentialAccessToken({ + requestId: 'req-1', + credentialId: 'cred-1', + toolId: 'gmail_send', + authenticate, + resolveManagedPrincipal, + }) + + expect(result).toEqual({ + ok: false, + status: 404, + code: 'MANAGED_CREDENTIAL_UNAUTHORIZED', + error: 'Managed credential not found', + }) + }) +}) diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index 1c93e142033..93694c24135 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -1,4 +1,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { + resolvePrincipalSubject, + type WorkflowExecutionDelegatedPrincipal, +} from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { impersonateEmailSchema, @@ -6,6 +10,10 @@ import { } from '@/lib/api/contracts/oauth-connections' import { authorizeCredentialUseForAuth } from '@/lib/auth/credential-access' import type { AuthResult } from '@/lib/auth/hybrid' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { InvalidManagedOAuthDelegationError } from '@/lib/credentials/application/managed-oauth-delegation' +import { resolveManagedOAuthCredentialToken } from '@/lib/credentials/application/resolve-managed-oauth-token' +import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { getCredential, @@ -19,7 +27,9 @@ import { MICROSOFT_DATAVERSE_PROVIDER_ID, } from '@/lib/oauth/microsoft-dataverse' import { extractSalesforceInstanceUrl, isSalesforceOAuthProviderId } from '@/lib/oauth/salesforce' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { captureServerEvent } from '@/lib/posthog/server' +import { getToolMetadata } from '@/tools/metadata' import { extractZohoDeskBaseFromScope } from '@/tools/zoho_desk/host-allowlist' const logger = createLogger('OAuthTokenResolution') @@ -50,8 +60,8 @@ export interface ResolveCredentialTokenInput { */ callerUserId?: string auditRequest?: CredentialAuditRequest - /** Reuses a credential lookup already performed by the route's managed-OAuth dispatch. */ - resolvedCredential?: ResolvedCredential | null + /** Credential lookup already performed by {@link resolveCredentialAccessToken}'s dispatch. */ + resolvedCredential: ResolvedCredential | null } export type ResolveCredentialTokenResult = @@ -161,9 +171,10 @@ export async function completeOAuthCredentialToken(params: { } /** - * Authorized application operation behind `POST /api/auth/oauth/token`. Every surface that - * needs a credential token — the route and the in-process tool executor — goes through - * here, so authorization, refresh, and audit cannot drift between them. + * Resolves a plain OAuth or service-account credential to a token for an + * authenticated caller. Managed OAuth credentials are dispatched one level up by + * {@link resolveCredentialAccessToken}, which every server surface goes through, + * so authorization, refresh, and audit cannot drift between surfaces. * * @param auth Result of authenticating the caller (session or internal JWT). */ @@ -192,16 +203,12 @@ export async function resolveCredentialToken( return { ok: false, status: 400, error: 'impersonateEmail must be a valid email address' } } - /** - * Both branches below authorize with the same arguments, and neither read depends - * on the other, so they resolve together — this runs per credentialed tool call. - */ - const [resolved, authz] = await Promise.all([ - input.resolvedCredential === undefined - ? resolveOAuthAccountId(credentialId) - : input.resolvedCredential, - authorizeCredentialUseForAuth(auth, { credentialId, workflowId, callerUserId }), - ]) + const resolved = input.resolvedCredential + const authz = await authorizeCredentialUseForAuth(auth, { + credentialId, + workflowId, + callerUserId, + }) if (resolved?.credentialType === 'service_account' && resolved.credentialId) { if (!authz.ok) { @@ -308,3 +315,165 @@ export async function resolveCredentialToken( return { ok: false, status: 500, error: 'Internal server error' } } } + +export interface ResolveCredentialAccessTokenInput + extends Omit { + /** Tool consuming the token; required by the managed-OAuth scope policy. */ + toolId?: string + /** + * Authenticates the caller for non-managed credentials. Invoked only when the + * credential is not managed OAuth, which authenticates through delegation instead. + */ + authenticate: () => AuthResult | Promise + /** + * Proves a workflow-execution delegation for one managed credential. The route + * verifies the delegation JWT header; the executor binds its delegation origin + * in-process. Absent, managed credentials are rejected with + * `MANAGED_CREDENTIAL_DELEGATION_REQUIRED`. Must throw + * {@link InvalidManagedOAuthDelegationError} on an invalid delegation. + */ + resolveManagedPrincipal?: (credentialId: string) => Promise +} + +/** + * Authorized application dispatch behind `POST /api/auth/oauth/token`. Every server + * surface that needs a credential token — the route and the in-process tool + * executor — goes through here, so the managed / service-account / plain-OAuth + * dispatch, authorization, refresh, audit, and analytics cannot drift between them. + */ +export async function resolveCredentialAccessToken( + input: ResolveCredentialAccessTokenInput +): Promise { + const { requestId, credentialId, toolId, auditRequest } = input + + const resolved = credentialId ? await resolveOAuthAccountId(credentialId) : null + + if (resolved?.credentialType !== 'managed_oauth' || !resolved.credentialId) { + const auth = await input.authenticate() + return resolveCredentialToken(auth, { + requestId, + credentialId, + workflowId: input.workflowId, + scopes: input.scopes, + /** + * In-process callers forward raw subblock state, where an untouched + * field is '' — treated as absent, matching what the wire contract + * (which rejects '') and the old truthy guards always produced. + */ + impersonateEmail: input.impersonateEmail || undefined, + callerUserId: input.callerUserId, + auditRequest, + resolvedCredential: resolved, + }) + } + + if (!input.resolveManagedPrincipal) { + return { + ok: false, + status: 403, + code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED', + error: 'Managed credentials can only be used by an authenticated workflow execution', + } + } + + let principal: WorkflowExecutionDelegatedPrincipal + try { + principal = await input.resolveManagedPrincipal(resolved.credentialId) + } catch (error) { + if (!(error instanceof InvalidManagedOAuthDelegationError)) throw error + return { + ok: false, + status: 401, + code: 'MANAGED_CREDENTIAL_DELEGATION_INVALID', + error: error.message, + } + } + + if (!toolId) { + return { + ok: false, + status: 400, + code: 'MANAGED_CREDENTIAL_TOOL_REQUIRED', + error: 'A tool ID is required to use a managed credential', + } + } + + const toolMetadata = getToolMetadata(toolId) + if (!toolMetadata?.oauth?.required) { + logger.error(`[${requestId}] Tool is not configured for managed OAuth`, { toolId }) + return { + ok: false, + status: 500, + code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED', + error: 'This tool is not configured to use managed credentials', + } + } + const requiredScopes = + toolMetadata.oauth.requiredScopes ?? getCanonicalScopesForProvider(toolMetadata.oauth.provider) + if (requiredScopes.length === 0) { + logger.error(`[${requestId}] Tool has no trusted OAuth scope policy`, { + toolId, + providerId: toolMetadata.oauth.provider, + }) + return { + ok: false, + status: 500, + code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED', + error: 'This tool is not configured to use managed credentials', + } + } + + try { + const result = await resolveManagedOAuthCredentialToken.execute({ + principal, + input: { + credentialId: resolved.credentialId, + expectedProviderId: toolMetadata.oauth.provider, + requiredScopes, + toolId, + }, + request: auditRequest, + }) + + const subject = resolvePrincipalSubject(principal) + if (subject?.kind === 'sim_user') { + captureServerEvent( + subject.userId, + 'credential_used', + { + credential_type: 'managed_oauth', + provider_id: toolMetadata.oauth.provider, + workspace_id: principal.workspaceId, + }, + { groups: { workspace: principal.workspaceId } } + ) + } + + return { + ok: true, + token: { + accessToken: result.accessToken, + idToken: result.idToken, + }, + } + } catch (error) { + if (error instanceof ManagedOAuthCredentialError) { + logger.warn(`[${requestId}] Managed OAuth credential rejected`, { + credentialId: resolved.credentialId, + code: error.code, + }) + return { ok: false, status: error.statusCode, code: error.code, error: error.message } + } + + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + return { + ok: false, + status: statusForOrchestrationError(orchestrationError.code), + code: 'MANAGED_CREDENTIAL_UNAUTHORIZED', + error: orchestrationError.message, + } + } + throw error + } +} diff --git a/apps/sim/lib/selectors/server/credentials.test.ts b/apps/sim/lib/selectors/server/credentials.test.ts index 691d951af5b..5d205fd7fc8 100644 --- a/apps/sim/lib/selectors/server/credentials.test.ts +++ b/apps/sim/lib/selectors/server/credentials.test.ts @@ -17,7 +17,7 @@ vi.mock('@/lib/auth/credential-access', () => ({ })) vi.mock('@/lib/oauth/credential-service', () => ({ - resolveCredentialAccessToken: vi.fn(), + resolveCredentialTokenBundle: vi.fn(), })) vi.mock('@/lib/oauth/utils', () => ({ diff --git a/apps/sim/lib/selectors/server/credentials.ts b/apps/sim/lib/selectors/server/credentials.ts index 645b3af5550..9229adf8c55 100644 --- a/apps/sim/lib/selectors/server/credentials.ts +++ b/apps/sim/lib/selectors/server/credentials.ts @@ -7,7 +7,7 @@ import { type CredentialAccessResult, } from '@/lib/auth/credential-access' import { AuthType } from '@/lib/auth/hybrid' -import { resolveCredentialAccessToken } from '@/lib/oauth/credential-service' +import { resolveCredentialTokenBundle } from '@/lib/oauth/credential-service' import { credentialProviderMatchesService, getServiceConfigByServiceId } from '@/lib/oauth/utils' import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' import type { @@ -123,7 +123,7 @@ export async function resolveSelectorOAuthAccessToken(input: { throw new SelectorConnectionUnavailableError() } - const result = await resolveCredentialAccessToken( + const result = await resolveCredentialTokenBundle( input.credential.suppliedId, access.credentialOwnerUserId, 'selector-execution', diff --git a/apps/sim/lib/selectors/server/providers/credential-bundle.test.ts b/apps/sim/lib/selectors/server/providers/credential-bundle.test.ts index b3e5b87e047..34ca50b57ff 100644 --- a/apps/sim/lib/selectors/server/providers/credential-bundle.test.ts +++ b/apps/sim/lib/selectors/server/providers/credential-bundle.test.ts @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mockResolveCredentialAccessToken = vi.hoisted(() => vi.fn()) vi.mock('@/lib/oauth/credential-service', () => ({ - resolveCredentialAccessToken: mockResolveCredentialAccessToken, + resolveCredentialTokenBundle: mockResolveCredentialAccessToken, })) import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' diff --git a/apps/sim/lib/selectors/server/providers/credential-bundle.ts b/apps/sim/lib/selectors/server/providers/credential-bundle.ts index d270409e2d8..7976f6c20ae 100644 --- a/apps/sim/lib/selectors/server/providers/credential-bundle.ts +++ b/apps/sim/lib/selectors/server/providers/credential-bundle.ts @@ -1,5 +1,5 @@ import { - resolveCredentialAccessToken, + resolveCredentialTokenBundle, type ServiceAccountTokenResult, } from '@/lib/oauth/credential-service' import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' @@ -35,7 +35,7 @@ export async function resolveSelectorCredentialBundle(input: { let bundle: ServiceAccountTokenResult | null try { - bundle = await resolveCredentialAccessToken( + bundle = await resolveCredentialTokenBundle( credential.suppliedId, ownerUserId, 'selector-execution', diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 87fc011bba7..9f7d0caf15d 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -57,7 +57,6 @@ const { mockRunWorkflowTool, mockReadAvailableCustomToolByIdOrTitleAsCopilot, mockReadAvailableCustomToolByIdOrTitleAsExecutor, - mockGenerateInternalDelegationToken, mockGenerateInternalToken, mockResolveWorkspaceFileReference, mockAssertPermissionsAllowed, @@ -78,7 +77,6 @@ const { mockRunWorkflowTool: vi.fn(), mockReadAvailableCustomToolByIdOrTitleAsCopilot: vi.fn(), mockReadAvailableCustomToolByIdOrTitleAsExecutor: vi.fn(), - mockGenerateInternalDelegationToken: vi.fn(), mockGenerateInternalToken: vi.fn(), mockResolveWorkspaceFileReference: vi.fn(), mockAssertPermissionsAllowed: vi.fn(), @@ -98,8 +96,6 @@ vi.mock('@/lib/api-key/byok', () => ({ })) vi.mock('@/lib/auth/internal', () => ({ - generateInternalDelegationToken: (...args: unknown[]) => - mockGenerateInternalDelegationToken(...args), generateInternalToken: (...args: unknown[]) => mockGenerateInternalToken(...args), })) @@ -156,6 +152,15 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () mockMarkWorkspaceFileSecretProvenanceUnknown(...args), })) +const { mockResolveExecutorCredentialToken } = vi.hoisted(() => ({ + mockResolveExecutorCredentialToken: vi.fn(), +})) + +vi.mock('@/executor/utils/credential-token', () => ({ + resolveExecutorCredentialToken: (...args: unknown[]) => + mockResolveExecutorCredentialToken(...args), +})) + vi.mock('@/executor/handlers/workflow/workflow-tool-runner', () => ({ runWorkflowTool: (...args: unknown[]) => mockRunWorkflowTool(...args), })) @@ -465,7 +470,6 @@ vi.spyOn(getQueryClientModule, 'getQueryClient').mockImplementation(createMockQu beforeEach(() => { vi.spyOn(getQueryClientModule, 'getQueryClient').mockImplementation(createMockQueryClient) mockAssertPermissionsAllowed.mockResolvedValue(undefined) - mockGenerateInternalDelegationToken.mockResolvedValue('executor-token') mockRunWorkflowTool.mockResolvedValue({ success: true, output: {} }) mockGetInternalToolOperationHandler.mockResolvedValue(mockExecuteInternalToolOperation) mockExecuteInternalToolOperation.mockImplementation(async (request: InternalToolOperationCall) => @@ -2989,15 +2993,7 @@ describe('Internal Route Trust', () => { ;(tools as Record)[ordinaryToolId] = createAuthorityTool(ordinaryToolId, false) const setTokenPayload = (payload: Record) => { - global.fetch = Object.assign( - vi.fn().mockResolvedValue( - new Response(JSON.stringify(payload), { - status: 200, - headers: { 'content-type': 'application/json' }, - }) - ), - { preconnect: vi.fn() } - ) as typeof fetch + mockResolveExecutorCredentialToken.mockResolvedValue(payload) } try { @@ -4429,21 +4425,15 @@ describe('Copilot OAuth Credential Enforcement', () => { describe('Managed OAuth Credential Delegation', () => { it('passes an opaque credential ID with trusted tool scope and origin-bound delegation', async () => { - mockGenerateInternalToken.mockResolvedValueOnce('legacy-token') - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ accessToken: 'managed-access-token' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ messages: [] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) + mockResolveExecutorCredentialToken.mockResolvedValueOnce({ + accessToken: 'managed-access-token', + }) + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ messages: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof fetch const executorDelegationOrigin = { @@ -4469,23 +4459,23 @@ describe('Managed OAuth Credential Delegation', () => { { executionContext: context } ) - expect(mockGenerateInternalDelegationToken).toHaveBeenCalledWith(executorDelegationOrigin) - const [tokenUrl, tokenRequest] = fetchMock.mock.calls[0] - expect(String(tokenUrl)).toContain('/api/auth/oauth/token') - expect(tokenRequest.headers).toMatchObject({ - Authorization: 'Bearer legacy-token', - 'x-sim-managed-oauth-delegation': 'Bearer executor-token', - }) - expect(JSON.parse(tokenRequest.body)).toMatchObject({ - credentialId: 'managed-credential-id', - toolId: 'gmail_read', - scopes: ['https://www.googleapis.com/auth/gmail.readonly'], - }) + expect(mockResolveExecutorCredentialToken).toHaveBeenCalledWith( + expect.objectContaining({ + credentialId: 'managed-credential-id', + toolId: 'gmail_read', + scopes: ['https://www.googleapis.com/auth/gmail.readonly'], + executorDelegationOrigin, + }) + ) + expect( + fetchMock.mock.calls.some(([url]) => String(url).includes('/api/auth/oauth/token')) + ).toBe(false) }) it('fails before transport when managed credential delegation lacks current workflow authority', async () => { - mockGenerateInternalDelegationToken.mockClear() - mockGenerateInternalToken.mockResolvedValueOnce('legacy-token') + mockResolveExecutorCredentialToken.mockRejectedValueOnce( + new Error('Managed credential delegation is missing current workflow authority') + ) const fetchMock = vi.fn() global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof fetch @@ -4520,7 +4510,6 @@ describe('Managed OAuth Credential Delegation', () => { success: false, error: 'Managed credential delegation is missing current workflow authority', }) - expect(mockGenerateInternalDelegationToken).not.toHaveBeenCalled() expect(fetchMock).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index d8a6f303019..31682d5894a 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -5,10 +5,12 @@ import { sleep } from '@sim/utils/helpers' import { isPlainRecord, isRecordLike } from '@sim/utils/object' import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' import { DrizzleQueryError } from 'drizzle-orm/errors' +import { ApiClientError } from '@/lib/api/client/errors' +import { requestJson } from '@/lib/api/client/request' import type { FunctionExecuteBody } from '@/lib/api/contracts' -import { MANAGED_OAUTH_DELEGATION_HEADER } from '@/lib/api/contracts/oauth-connections' +import { oauthTokenPostContract } from '@/lib/api/contracts/oauth-connections' import { getBYOKKey } from '@/lib/api-key/byok' -import { generateInternalToken, type InternalSandboxProfile } from '@/lib/auth/internal' +import type { InternalSandboxProfile } from '@/lib/auth/internal' import { BILLING_ATTRIBUTION_HEADER, type BillingAttributionSnapshot, @@ -75,7 +77,6 @@ import { assertPermissionsAllowed } from '@/ee/access-control/utils/permission-c import { isCustomTool, isMcpTool } from '@/executor/constants' import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver' import type { ExecutionContext, UserFile } from '@/executor/types' -import { buildExecutorDelegationHeaders } from '@/executor/utils/http' import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection' import { @@ -94,7 +95,6 @@ import type { BYOKProviderId, ExecutableToolConfig, InternalToolConfig, - OAuthTokenPayload, ToolConfig, ToolDefinition, ToolHostingPricing, @@ -1533,6 +1533,46 @@ function getPrivateToolMetadataPolicy(toolId: string): PrivateToolMetadataPolicy return undefined } +/** + * Resolves a credential token from the browser through `POST /api/auth/oauth/token`, + * authenticated by the session cookie. Server-side execution resolves in-process + * through `resolveExecutorCredentialToken` instead; this HTTP path exists only + * because the browser holds no server credentials. + */ +async function fetchCredentialTokenFromRoute(params: { + requestId: string + toolId: string + toolLabel: string + credentialId: string + workflowId?: string + impersonateEmail?: string + scopes?: string[] + callerUserId?: string +}): Promise { + const { requestId, toolId, toolLabel, credentialId, workflowId } = params + + try { + return await requestJson(oauthTokenPostContract, { + query: { userId: params.callerUserId }, + headers: {}, + body: { + credentialId, + toolId, + ...(workflowId ? { workflowId } : {}), + ...(params.impersonateEmail ? { impersonateEmail: params.impersonateEmail } : {}), + ...(params.scopes ? { scopes: params.scopes } : {}), + }, + }) + } catch (error: unknown) { + const status = error instanceof ApiClientError ? error.status : undefined + logger.error(`[${requestId}] Token fetch failed for ${toolId}:`, { + status, + error: getErrorMessage(error), + }) + throw new Error(`Failed to obtain credential for ${toolLabel}: ${getErrorMessage(error)}`) + } +} + /** * Runs private-provenance tools against an isolated registry. Unavailable authenticated lineage * marks the parent unknown without replacing the tool's functional result; malformed metadata is @@ -1779,107 +1819,62 @@ async function executeToolImplementation( try { const workflowId = scope.workflowId const userId = scope.userId + const credentialId = contextParams.credential as string + const toolLabel = tool?.name || toolId + const impersonateEmail = contextParams.impersonateUserEmail as string | undefined - const tokenPayload: OAuthTokenPayload = { - credentialId: contextParams.credential as string, - toolId, - } - if (workflowId) { - tokenPayload.workflowId = workflowId - } - if (contextParams.impersonateUserEmail) { - tokenPayload.impersonateEmail = contextParams.impersonateUserEmail as string - } + let providerScopes: string[] | undefined if (tool?.oauth?.provider) { - const providerScopes = + const scopesForProvider = tool.oauth.requiredScopes ?? (await import('@/lib/oauth/utils')).getCanonicalScopesForProvider(tool.oauth.provider) - if (providerScopes.length > 0) { - tokenPayload.scopes = providerScopes + if (scopesForProvider.length > 0) { + providerScopes = scopesForProvider } } /** - * The acting user asserted alongside an internal token. Only sent when the - * run enforces credential access, matching the `userId` query param the HTTP - * surface accepted — it never widens access, it only pins the assertion to - * the token subject. + * The acting user asserted alongside the credential. Only asserted when the + * run enforces credential access — it never widens access, it only pins the + * assertion to the authenticated subject. */ - const callerUserId = - userId && contextParams._context?.enforceCredentialAccess ? userId : undefined - - const baseUrl = getInternalApiBaseUrl() - logger.info(`[${requestId}] Fetching access token from ${baseUrl}/api/auth/oauth/token`) - - const tokenUrlObj = new URL('/api/auth/oauth/token', baseUrl) - if (workflowId) { - tokenUrlObj.searchParams.set('workflowId', workflowId) - } - if (callerUserId) { - tokenUrlObj.searchParams.set('userId', callerUserId) - } + const enforceCredentialAccess = Boolean(contextParams._context?.enforceCredentialAccess) - /** - * Deliberately an HTTP hop rather than an in-process call to - * `resolveCredentialToken`, even though both run the same authorization rule. - * - * An OAuth refresh needs the provider's client id and secret - * (`requireOAuthClientCapability`, which THROWS when they are absent). Only the - * app container loads those, from `SIM_ENV_SECRET_ID`. Tool calls execute inside - * the Trigger.dev worker, whose environment does not carry them, so resolving - * in-process there turns every credential whose access token has expired into - * `Failed to refresh access token`. A still-valid token hides it — the refresh - * path is only reached once the token lapses. - * - * Moving this in-process requires the worker to hold the OAuth client config, - * not just a code change. - */ - const tokenHeaders: Record = { 'Content-Type': 'application/json' } + let data: CredentialTokenPayload if (typeof window === 'undefined') { - const managedCredentialDelegation = executionContext?.executorDelegationOrigin - if (managedCredentialDelegation && !managedCredentialDelegation.currentWorkflow) { - throw new Error('Managed credential delegation is missing current workflow authority') - } - try { - const internalToken = await generateInternalToken(userId) - tokenHeaders.Authorization = `Bearer ${internalToken}` - } catch (_e) { - // Swallow token generation errors; the request will fail and be reported upstream - } - if (managedCredentialDelegation) { - const delegationHeaders = await buildExecutorDelegationHeaders( - managedCredentialDelegation - ) - tokenHeaders[MANAGED_OAUTH_DELEGATION_HEADER] = delegationHeaders.Authorization - } - } - - // boundary-raw-fetch: same-origin token route, authenticated by internal JWT on the server and the session cookie in the browser - const response = await fetch(tokenUrlObj.toString(), { - method: 'POST', - headers: tokenHeaders, - body: JSON.stringify(tokenPayload), - }) - - if (!response.ok) { - const errorText = await response.text() - logger.error(`[${requestId}] Token fetch failed for ${toolId}:`, { - status: response.status, - error: errorText, + /** + * Dynamic import for the same client-bundle reason as the workflow_executor + * runner below: the resolver pulls the db/audit dependency graph, which must + * never enter the client-bundled tool registry. + */ + const { resolveExecutorCredentialToken } = await import( + '@/executor/utils/credential-token' + ) + data = await resolveExecutorCredentialToken({ + requestId, + credentialId, + userId, + workflowId, + toolId, + toolLabel, + scopes: providerScopes, + impersonateEmail, + enforceCredentialAccess, + executorDelegationOrigin: executionContext?.executorDelegationOrigin, + }) + } else { + data = await fetchCredentialTokenFromRoute({ + requestId, + toolId, + toolLabel, + credentialId, + workflowId, + impersonateEmail, + scopes: providerScopes, + callerUserId: userId && enforceCredentialAccess ? userId : undefined, }) - let parsedError = errorText - try { - const parsed = JSON.parse(errorText) - if (parsed.error) parsedError = parsed.error - } catch { - // Use raw text - } - const toolLabel = tool?.name || toolId - throw new Error(`Failed to obtain credential for ${toolLabel}: ${parsedError}`) } - const data = (await response.json()) as CredentialTokenPayload - if (tool.oauth?.credentialKind) { const actualCredentialKind = data.credentialType === 'service_account' diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 9fbc54ff793..1d07dc3d19b 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -299,16 +299,6 @@ export interface TableRow { } } -export interface OAuthTokenPayload { - credentialId?: string - credentialAccountUserId?: string - providerId?: string - toolId?: string - workflowId?: string - impersonateEmail?: string - scopes?: string[] -} - /** * File data that tools can return for file-typed outputs */