Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 193 additions & 4 deletions apps/sim/lib/oauth/token-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down
78 changes: 75 additions & 3 deletions apps/sim/lib/oauth/token-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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. */
Expand All @@ -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<boolean> {
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<string>()
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 } {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -365,8 +438,6 @@ export async function resolveCredentialToken(

export interface ResolveCredentialAccessTokenInput
extends Omit<ResolveCredentialTokenInput, 'resolvedCredential'> {
/** 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.
Expand Down Expand Up @@ -400,6 +471,7 @@ export async function resolveCredentialAccessToken(
return resolveCredentialToken(auth, {
requestId,
credentialId,
toolId,
workflowId: input.workflowId,
scopes: input.scopes,
/**
Expand Down
Loading