diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index 799c21748c8..bee0e2acedd 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -77,15 +77,13 @@ 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 { resolveCredentialAccessToken, resolveCredentialToken } from '@/lib/oauth/token-resolution' +import { getBlockRegistry } from '@/blocks/registry' +import type { BlockConfig } from '@/blocks/types' const INTERNAL_AUTH = { success: true, userId: 'user-1', authType: 'internal_jwt' } as const @@ -444,6 +442,197 @@ describe('resolveCredentialAccessToken', () => { }) }) + describe('consuming-tool credential compatibility', () => { + const run = () => + resolveCredentialAccessToken({ + requestId: 'req-compatibility', + credentialId: 'supplied-alias', + workflowId: 'wf-1', + toolId: 'registered_tool', + authenticate, + }) + + function selectCredential(providerId: string, kind: 'oauth' | 'service-account') { + mockResolveOAuthAccountId.mockResolvedValue({ + credentialType: kind === 'service-account' ? 'service_account' : 'oauth', + credentialId: 'canonical-1', + accountId: 'canonical-1', + providerId, + workspaceId: 'ws-1', + usedCredentialTable: true, + }) + mockGetCredential.mockResolvedValue({ providerId }) + } + + function legacyOwner(serviceId: string): BlockConfig { + return { + tools: { access: ['registered_tool'] }, + subBlocks: [{ id: 'oauthCredential', type: 'oauth-input', serviceId }], + } as BlockConfig + } + + beforeEach(() => { + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: true, + requesterUserId: 'user-1', + credentialOwnerUserId: 'owner-1', + workspaceId: 'ws-1', + resolvedCredentialId: 'canonical-1', + }) + mockResolveServiceAccountToken.mockResolvedValue({ accessToken: 'minted' }) + mockRefreshTokenIfNeeded.mockResolvedValue({ accessToken: 'refreshed' }) + vi.mocked(getBlockRegistry).mockReturnValue({}) + }) + + it.each([ + { service: 'salesforce', provider: 'salesforce', kind: 'oauth', accepted: true }, + { service: 'salesforce', provider: 'salesforce-sandbox', kind: 'oauth', accepted: true }, + { service: 'salesforce', provider: 'jira', kind: 'oauth', accepted: false }, + { + service: 'jira', + provider: 'atlassian-service-account', + kind: 'service-account', + accepted: true, + }, + { + service: 'confluence', + provider: 'atlassian-service-account', + kind: 'service-account', + accepted: true, + }, + { + service: 'netsuite', + provider: 'snowflake-service-account', + kind: 'service-account', + accepted: false, + }, + { service: 'netsuite', provider: 'netsuite', kind: 'oauth', accepted: false }, + { + service: 'jira', + provider: 'atlassian-service-account', + kind: 'oauth', + accepted: false, + }, + ] as const)('$service with $provider ($kind): accepted=$accepted', async (testCase) => { + selectCredential(testCase.provider, testCase.kind) + mockGetToolMetadata.mockReturnValue({ + id: 'registered_tool', + oauth: { required: true, provider: testCase.service }, + }) + + const result = await run() + + expect(result.ok).toBe(testCase.accepted) + expect(mockAuthorizeCredentialUseForAuth).toHaveBeenCalledWith(INTERNAL_AUTH, { + credentialId: 'supplied-alias', + workflowId: 'wf-1', + callerUserId: undefined, + }) + if (!testCase.accepted) { + expect(result).toEqual({ + ok: false, + status: 403, + code: 'CREDENTIAL_TOOL_MISMATCH', + error: 'Credential is not compatible with this tool', + }) + expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() + expect(mockRefreshTokenIfNeeded).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + } else if (testCase.kind === 'service-account') { + expect(mockResolveServiceAccountToken).toHaveBeenCalledWith( + 'canonical-1', + testCase.provider, + [], + undefined + ) + } else { + expect(mockGetCredential).toHaveBeenCalledWith( + 'req-compatibility', + 'canonical-1', + 'owner-1' + ) + expect(mockRefreshTokenIfNeeded).toHaveBeenCalledWith( + 'req-compatibility', + { providerId: testCase.provider }, + 'canonical-1' + ) + } + }) + + it('honors an explicit kind restriction before minting an otherwise compatible provider', async () => { + selectCredential('atlassian-service-account', 'service-account') + mockGetToolMetadata.mockReturnValue({ + oauth: { required: true, provider: 'jira', credentialKind: 'oauth' }, + }) + + expect(await run()).toMatchObject({ ok: false, code: 'CREDENTIAL_TOOL_MISMATCH' }) + expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() + }) + + it.each(['oauth', 'service-account'] as const)( + 'does not inspect tool compatibility or return a token before %s access is authorized', + async (kind) => { + selectCredential('unrelated-provider', kind) + mockAuthorizeCredentialUseForAuth.mockResolvedValue({ + ok: false, + error: 'Credential is not accessible from this workflow workspace', + }) + + expect(await run()).toEqual({ + ok: false, + status: 403, + error: 'Credential is not accessible from this workflow workspace', + }) + expect(mockGetToolMetadata).not.toHaveBeenCalled() + expect(mockGetCredential).not.toHaveBeenCalled() + expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() + expect(mockRefreshTokenIfNeeded).not.toHaveBeenCalled() + } + ) + + it('keeps legacy tools with one explicitly declared owning service working', async () => { + selectCredential('netsuite-service-account', 'service-account') + mockGetToolMetadata.mockReturnValue({ + id: 'registered_tool', + params: { oauthCredential: { type: 'string', required: true } }, + }) + vi.mocked(getBlockRegistry).mockReturnValue({ + first: legacyOwner('netsuite'), + second: legacyOwner('netsuite'), + }) + + expect(await run()).toMatchObject({ ok: true, token: { accessToken: 'minted' } }) + }) + + it.each(['missing owner', 'ambiguous owners', 'unknown service', 'unknown tool'])( + 'rejects a %s instead of guessing the consuming service', + async (failure) => { + selectCredential('netsuite-service-account', 'service-account') + mockGetToolMetadata.mockReturnValue({ + id: 'registered_tool', + params: { oauthCredential: { type: 'string', required: true } }, + }) + if (failure === 'ambiguous owners') { + vi.mocked(getBlockRegistry).mockReturnValue({ + first: legacyOwner('netsuite'), + second: legacyOwner('snowflake'), + }) + } else if (failure === 'unknown service') { + mockGetToolMetadata.mockReturnValue({ + oauth: { required: true, provider: 'unregistered-service' }, + }) + vi.mocked(getBlockRegistry).mockReturnValue({ first: legacyOwner('netsuite') }) + } else if (failure === 'unknown tool') { + mockGetToolMetadata.mockReturnValue(undefined) + } + + expect(await run()).toMatchObject({ ok: false, code: 'CREDENTIAL_TOOL_MISMATCH' }) + expect(mockResolveServiceAccountToken).not.toHaveBeenCalled() + expect(mockRefreshTokenIfNeeded).not.toHaveBeenCalled() + } + ) + }) + it('rejects a managed credential when no delegation resolver is wired', async () => { mockResolveOAuthAccountId.mockResolvedValue(MANAGED_RESOLVED) diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index 6186de18b16..632e1b46f25 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -26,7 +26,13 @@ import { } from '@/lib/oauth/microsoft-dataverse' import { parseQuickBooksAccountId } from '@/lib/oauth/quickbooks' import { extractSalesforceInstanceUrl, isSalesforceOAuthProviderId } from '@/lib/oauth/salesforce' -import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import type { OAuthServiceConfig } from '@/lib/oauth/types' +import { + credentialProviderMatchesService, + getCanonicalScopesForProvider, + getServiceConfigByProviderId, + getServiceConfigByServiceId, +} from '@/lib/oauth/utils' import { captureServerEvent } from '@/lib/posthog/server' import { getToolMetadata } from '@/tools/metadata' import { extractZohoDeskBaseFromScope } from '@/tools/zoho_desk/host-allowlist' @@ -49,6 +55,8 @@ export interface ResolveCredentialTokenInput { requestId: string credentialId?: string workflowId?: string + /** Registered tool consuming the credential; omitted for non-tool token consumers. */ + toolId?: string /** Canonical provider scopes, used only by service-account token minting. */ scopes?: string[] /** Google domain-wide-delegation subject for service-account credentials. */ @@ -72,6 +80,63 @@ interface OAuthCredentialContext { accountId?: string | null } +/** Validates the consuming service after credential access, before minting or refreshing tokens. */ +async function credentialMatchesTool( + toolId: string | undefined, + providerId: string | undefined, + kind: 'oauth' | 'service-account' +): Promise { + if (toolId === undefined) return true + const tool = getToolMetadata(toolId) + if (!tool || !providerId) return false + + let service: OAuthServiceConfig | null + if (tool.oauth) { + service = + getServiceConfigByServiceId(tool.oauth.provider) ?? + getServiceConfigByProviderId(tool.oauth.provider) + if (tool.oauth.credentialKind && tool.oauth.credentialKind !== kind) return false + } else { + /** + * Legacy tools declare their selector but keep its service on the owning block. + * Use exact registry membership and unanimous declarations, never tool-name prefixes + * or the selected credential's provider to infer the consuming service. + */ + if (!tool.params.oauthCredential && !tool.params.credential) return false + const { getBlockRegistry } = await import('@/blocks/registry') + const owners = Object.values(getBlockRegistry()).filter((block) => + block.tools?.access?.includes(tool.id) + ) + if (owners.length === 0) return false + const serviceIds = new Set() + for (const owner of owners) { + const selectors = owner.subBlocks.filter((subBlock) => subBlock.type === 'oauth-input') + if (selectors.length === 0) return false + for (const selector of selectors) { + if (!selector.serviceId) return false + serviceIds.add(selector.serviceId) + } + } + if (serviceIds.size !== 1) return false + service = getServiceConfigByServiceId([...serviceIds][0]) + } + + if (!service || !credentialProviderMatchesService(providerId, service)) return false + const serviceAccountProviderId = + service.serviceAccountProviderId ?? + (service.authType === 'service_account' ? service.providerId : undefined) + return kind === 'service-account' + ? providerId === serviceAccountProviderId + : service.authType !== 'service_account' && providerId !== serviceAccountProviderId +} + +const INCOMPATIBLE_TOOL_CREDENTIAL = { + ok: false, + status: 403, + code: 'CREDENTIAL_TOOL_MISMATCH', + error: 'Credential is not compatible with this tool', +} as const + export function validateOAuthCredentialContext( credential: OAuthCredentialContext ): { ok: true } | { ok: false; error: string } { @@ -262,6 +327,10 @@ export async function resolveCredentialToken( return { ok: false, status: 403, error: authz.error || 'Unauthorized' } } + if (!(await credentialMatchesTool(input.toolId, resolved.providerId, 'service-account'))) { + return INCOMPATIBLE_TOOL_CREDENTIAL + } + const saActorId = authz.requesterUserId const saWorkspaceId = resolved.workspaceId ?? authz.workspaceId ?? null @@ -349,6 +418,10 @@ export async function resolveCredentialToken( return { ok: false, status: 404, error: 'Credential not found' } } + if (!(await credentialMatchesTool(input.toolId, credential.providerId, 'oauth'))) { + return INCOMPATIBLE_TOOL_CREDENTIAL + } + return completeOAuthCredentialToken({ requestId, credential, @@ -365,8 +438,6 @@ export async function resolveCredentialToken( 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. @@ -400,6 +471,7 @@ export async function resolveCredentialAccessToken( return resolveCredentialToken(auth, { requestId, credentialId, + toolId, workflowId: input.workflowId, scopes: input.scopes, /**