From a9c195777971f725c67a4f96747b1d8606fab185 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 26 Aug 2026 12:12:17 -0700 Subject: [PATCH 1/4] fix(slack): resolve direct-token selector contexts server-side --- .../sim/app/api/tools/slack/channels/route.ts | 74 +++---- .../slack/server-resolved-selectors.test.ts | 195 +++++++++++++++++ apps/sim/app/api/tools/slack/users/route.ts | 60 ++---- .../selector-input/selector-input.tsx | 4 +- .../sub-block/hooks/use-selector-setup.ts | 1 + .../editor/components/sub-block/sub-block.tsx | 12 +- .../selectors/providers/slack/context.ts | 18 ++ .../selectors/providers/slack/selectors.ts | 32 ++- .../slack/server-resolved-context.test.ts | 100 +++++++++ apps/sim/lib/api/contracts/selectors/slack.ts | 8 +- .../selectors/server/slack-credential.test.ts | 197 ++++++++++++++++++ .../lib/selectors/server/slack-credential.ts | 81 +++++++ 12 files changed, 671 insertions(+), 111 deletions(-) create mode 100644 apps/sim/app/api/tools/slack/server-resolved-selectors.test.ts create mode 100644 apps/sim/hooks/selectors/providers/slack/context.ts create mode 100644 apps/sim/hooks/selectors/providers/slack/server-resolved-context.test.ts create mode 100644 apps/sim/lib/selectors/server/slack-credential.test.ts create mode 100644 apps/sim/lib/selectors/server/slack-credential.ts diff --git a/apps/sim/app/api/tools/slack/channels/route.ts b/apps/sim/app/api/tools/slack/channels/route.ts index 3648f70e4fd..73afdf37184 100644 --- a/apps/sim/app/api/tools/slack/channels/route.ts +++ b/apps/sim/app/api/tools/slack/channels/route.ts @@ -5,11 +5,11 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { slackChannelsSelectorContract } from '@/lib/api/contracts/selectors/slack' import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' +import { authenticateSelectorRequest } from '@/lib/selectors/server/resolve-authorized-context' +import { resolveSlackSelectorCredential } from '@/lib/selectors/server/slack-credential' export const dynamic = 'force-dynamic' @@ -49,6 +49,10 @@ function parseScopedSlackUserId(accountId: string): string | null { export const POST = withRouteHandler(async (request: NextRequest) => { try { const requestId = generateRequestId() + const authentication = await authenticateSelectorRequest(request) + if (!authentication.ok) { + return NextResponse.json({ error: authentication.error }, { status: authentication.status }) + } const parsed = await parseRequest(slackChannelsSelectorContract, request, {}) if (!parsed.success) { logger.error('Missing credential in request') @@ -56,61 +60,36 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const { credential, workflowId } = parsed.data.body - let accessToken: string - let isBotToken = false let scopedUserId: string | null = null - - if (credential.startsWith('xoxb-')) { - accessToken = credential - isBotToken = true - logger.info('Using direct bot token for Slack API') - } else { - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId: workflowId ?? undefined, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - const resolvedToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId + const resolvedCredential = await resolveSlackSelectorCredential(authentication.principal, { + credential, + workflowId, + requestId, + }) + if (!resolvedCredential.ok) { + return NextResponse.json( + { error: resolvedCredential.error }, + { status: resolvedCredential.status } ) - if (!resolvedToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { - error: 'Could not retrieve access token', - authRequired: true, - }, - { status: 401 } - ) - } - accessToken = resolvedToken + } + const { accessToken, isBotToken, credentialAccess } = resolvedCredential + if (!isBotToken && credentialAccess) { // resolvedCredentialId is an account.id only for OAuth credentials // (the service_account path returns a credential.id). - if (authz.credentialType === 'oauth' && authz.resolvedCredentialId) { + if (credentialAccess.credentialType === 'oauth' && credentialAccess.resolvedCredentialId) { logger.info('Using OAuth token for Slack API') const [accountRow] = await db .select({ accountId: account.accountId }) .from(account) - .where(eq(account.id, authz.resolvedCredentialId)) + .where(eq(account.id, credentialAccess.resolvedCredentialId)) .limit(1) if (accountRow) { scopedUserId = parseScopedSlackUserId(accountRow.accountId) } - } else { - // A custom-bot service_account credential resolves to a bot token with - // no scoped user; treat it like a direct bot token so the private -> - // public channel fallback applies. - isBotToken = true - logger.info('Using custom bot token for Slack API') } + } else { + logger.info('Using bot token for Slack API') } let data: SlackConversationsResult @@ -225,12 +204,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userScoped: !!scopedUserId, }) return NextResponse.json({ channels }) - } catch (error) { - logger.error('Error processing Slack channels request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Slack channels', details: (error as Error).message }, - { status: 500 } - ) + } catch { + logger.error('Error processing Slack channels request') + return NextResponse.json({ error: 'Failed to retrieve Slack channels' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/tools/slack/server-resolved-selectors.test.ts b/apps/sim/app/api/tools/slack/server-resolved-selectors.test.ts new file mode 100644 index 00000000000..7346b20f35a --- /dev/null +++ b/apps/sim/app/api/tools/slack/server-resolved-selectors.test.ts @@ -0,0 +1,195 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + resolveSlackCredential: vi.fn(), +})) + +vi.mock('@/lib/selectors/server/resolve-authorized-context', () => ({ + authenticateSelectorRequest: mocks.authenticate, +})) + +vi.mock('@/lib/selectors/server/slack-credential', () => ({ + resolveSlackSelectorCredential: mocks.resolveSlackCredential, +})) + +import { POST as listChannels } from '@/app/api/tools/slack/channels/route' +import { POST as listUsers } from '@/app/api/tools/slack/users/route' + +function request(path: string, body: unknown) { + return createMockRequest('POST', body, {}, `http://localhost:3000${path}`) +} + +describe('server-resolved Slack selectors', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue({ + ok: true, + principal: { kind: 'session', userId: 'viewer-1', sessionId: 'session-1' }, + }) + mocks.resolveSlackCredential.mockResolvedValue({ + ok: true, + accessToken: 'xoxb-resolved', + isBotToken: true, + }) + }) + + it('authenticates before parsing malformed requests', async () => { + mocks.authenticate.mockResolvedValue({ ok: false, status: 401, error: 'Unauthorized' }) + + const response = await listChannels(request('/api/tools/slack/channels', {})) + + expect(response.status).toBe(401) + expect(mocks.resolveSlackCredential).not.toHaveBeenCalled() + }) + + it('passes raw references to the authorized credential resolver and short-circuits denial', async () => { + mocks.resolveSlackCredential.mockResolvedValue({ + ok: false, + status: 400, + error: 'Unable to resolve selector configuration', + }) + const providerFetch = vi.fn() + vi.stubGlobal('fetch', providerFetch) + + const response = await listChannels( + request('/api/tools/slack/channels', { + credential: '{{INACCESSIBLE_TOKEN}}', + workflowId: 'workflow-1', + }) + ) + + expect(response.status).toBe(400) + expect(mocks.resolveSlackCredential).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + credential: '{{INACCESSIBLE_TOKEN}}', + workflowId: 'workflow-1', + }) + ) + expect(providerFetch).not.toHaveBeenCalled() + }) + + it('supports a workflowless stored credential through the route', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + Response.json({ + ok: true, + channels: [{ id: 'C111', name: 'general', is_private: false, is_archived: false }], + response_metadata: { next_cursor: '' }, + }) + ) + ) + + const response = await listChannels( + request('/api/tools/slack/channels', { credential: 'credential-1' }) + ) + + expect(response.status).toBe(200) + expect(mocks.resolveSlackCredential).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ credential: 'credential-1', workflowId: undefined }) + ) + }) + + it('paginates channels and preserves bot-token private-channel filtering', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce( + Response.json({ + ok: true, + channels: [ + { + id: 'C111', + name: 'general', + is_private: false, + is_archived: false, + is_member: false, + }, + { + id: 'G222', + name: 'private-member', + is_private: true, + is_archived: false, + is_member: true, + }, + { + id: 'G333', + name: 'private-not-member', + is_private: true, + is_archived: false, + is_member: false, + }, + ], + response_metadata: { next_cursor: 'page-2' }, + }) + ) + .mockResolvedValueOnce( + Response.json({ + ok: true, + channels: [ + { + id: 'C444', + name: 'announcements', + is_private: false, + is_archived: false, + is_member: false, + }, + ], + response_metadata: { next_cursor: '' }, + }) + ) + ) + + const response = await listChannels( + request('/api/tools/slack/channels', { + credential: 'xoxb-literal-secret', + workflowId: 'workflow-1', + }) + ) + + expect(await response.json()).toEqual({ + channels: [ + { id: 'C111', name: 'general', isPrivate: false }, + { id: 'G222', name: 'private-member', isPrivate: true }, + { id: 'C444', name: 'announcements', isPrivate: false }, + ], + }) + expect(String(vi.mocked(fetch).mock.calls[1][0])).toContain('cursor=page-2') + }) + + it('maps users and filters deleted users and bots', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + Response.json({ + ok: true, + members: [ + { id: 'U111', name: 'bill', real_name: 'Bill', deleted: false, is_bot: false }, + { id: 'U222', name: 'bot', real_name: 'Bot', deleted: false, is_bot: true }, + { id: 'U333', name: 'old', real_name: 'Old', deleted: true, is_bot: false }, + ], + response_metadata: { next_cursor: '' }, + }) + ) + ) + + const response = await listUsers( + request('/api/tools/slack/users', { + credential: '{{SLACK_BOT_TOKEN}}', + workflowId: 'workflow-1', + }) + ) + + expect(await response.json()).toEqual({ + users: [{ id: 'U111', name: 'bill', real_name: 'Bill' }], + }) + }) +}) diff --git a/apps/sim/app/api/tools/slack/users/route.ts b/apps/sim/app/api/tools/slack/users/route.ts index 6d0ae67e31a..84782a0bc06 100644 --- a/apps/sim/app/api/tools/slack/users/route.ts +++ b/apps/sim/app/api/tools/slack/users/route.ts @@ -2,11 +2,11 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { slackUsersListOrDetailContract } from '@/lib/api/contracts/selectors/slack' import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' +import { authenticateSelectorRequest } from '@/lib/selectors/server/resolve-authorized-context' +import { resolveSlackSelectorCredential } from '@/lib/selectors/server/slack-credential' export const dynamic = 'force-dynamic' @@ -31,6 +31,10 @@ interface SlackUsersResult { export const POST = withRouteHandler(async (request: NextRequest) => { try { const requestId = generateRequestId() + const authentication = await authenticateSelectorRequest(request) + if (!authentication.ok) { + return NextResponse.json({ error: authentication.error }, { status: authentication.status }) + } const parsed = await parseRequest(slackUsersListOrDetailContract, request, {}) if (!parsed.success) { logger.error('Missing credential in request') @@ -46,41 +50,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } - let accessToken: string - const isBotToken = credential.startsWith('xoxb-') - - if (isBotToken) { - accessToken = credential - logger.info('Using direct bot token for Slack API') - } else { - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - const resolvedToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId + const resolvedCredential = await resolveSlackSelectorCredential(authentication.principal, { + credential, + workflowId, + requestId, + }) + if (!resolvedCredential.ok) { + return NextResponse.json( + { error: resolvedCredential.error }, + { status: resolvedCredential.status } ) - if (!resolvedToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { - error: 'Could not retrieve access token', - authRequired: true, - }, - { status: 401 } - ) - } - accessToken = resolvedToken - logger.info('Using OAuth token for Slack API') } + const { accessToken, isBotToken } = resolvedCredential if (userId) { const userData = await fetchSlackUser(accessToken, userId) @@ -111,12 +92,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { tokenType: isBotToken ? 'bot_token' : 'oauth', }) return NextResponse.json({ users }) - } catch (error) { - logger.error('Error processing Slack users request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Slack users', details: (error as Error).message }, - { status: 500 } - ) + } catch { + logger.error('Error processing Slack users request') + return NextResponse.json({ error: 'Failed to retrieve Slack users' }, { status: 500 }) } }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-input/selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-input/selector-input.tsx index 332ca77b014..fbaab55d358 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-input/selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-input/selector-input.tsx @@ -42,11 +42,11 @@ export function SelectorInput({ selectorContext: autoContext, allowSearch, disabled: selectorDisabled, - dependencyValues, + rawDependencyValues, } = useSelectorSetup(blockId, subBlock, { disabled, isPreview, previewContextValues }) const selectorContext = overrides?.transformContext - ? overrides.transformContext(autoContext, dependencyValues) + ? overrides.transformContext(autoContext, rawDependencyValues) : autoContext useEffect(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts index 04258f9c6fe..a132760dc8b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts @@ -105,5 +105,6 @@ export function useSelectorSetup( allowSearch: subBlock.selectorAllowSearch ?? true, disabled: finalDisabled || !subBlock.selectorKey, dependencyValues: resolvedDependencyValues, + rawDependencyValues: dependencyValues, } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 9833b32824e..143a78a7c1d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -52,19 +52,11 @@ import { import { MODAL_REGISTRY } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/modal-registry' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import type { SubBlockConfig } from '@/blocks/types' +import { transformSlackSelectorContext } from '@/hooks/selectors/providers/slack/context' import { useWebhookManagement } from '@/hooks/use-webhook-management' const SLACK_OVERRIDES: SelectorOverrides = { - transformContext: (context, deps) => { - // v1 gates on authMethod (raw bot token vs OAuth); v2 has one merged - // credential field for actions and customBotCredential for triggers. - const authMethod = deps.authMethod as string - const oauthCredential = - authMethod === 'bot_token' - ? String(deps.botToken ?? '') - : String(deps.credential ?? deps.customBotCredential ?? '') - return { ...context, oauthCredential } - }, + transformContext: transformSlackSelectorContext, } const FOLDER_OVERRIDES: SelectorOverrides = { diff --git a/apps/sim/hooks/selectors/providers/slack/context.ts b/apps/sim/hooks/selectors/providers/slack/context.ts new file mode 100644 index 00000000000..606def97d58 --- /dev/null +++ b/apps/sim/hooks/selectors/providers/slack/context.ts @@ -0,0 +1,18 @@ +import { isReference } from '@/executor/constants' +import type { SelectorContext } from '@/hooks/selectors/types' + +/** Maps legacy Slack auth fields onto the shared selector credential context. */ +export function transformSlackSelectorContext( + context: SelectorContext, + dependencies: Record +): SelectorContext { + const authMethod = dependencies.authMethod as string + const oauthCredential = + authMethod === 'bot_token' + ? String(dependencies.botToken ?? '') + : String(dependencies.credential ?? dependencies.customBotCredential ?? '') + return { + ...context, + oauthCredential: isReference(oauthCredential) ? undefined : oauthCredential, + } +} diff --git a/apps/sim/hooks/selectors/providers/slack/selectors.ts b/apps/sim/hooks/selectors/providers/slack/selectors.ts index ad5474dc912..3df993345ce 100644 --- a/apps/sim/hooks/selectors/providers/slack/selectors.ts +++ b/apps/sim/hooks/selectors/providers/slack/selectors.ts @@ -1,25 +1,40 @@ import { requestJson } from '@/lib/api/client/request' import * as selectorContracts from '@/lib/api/contracts/selectors' +import { isEnvVarReference } from '@/executor/constants' import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' +function slackCredentialQueryKey(credential: string | undefined): string { + if (!credential) return 'none' + return credential.startsWith('xoxb-') ? 'direct-bot-token' : credential +} + +function slackCredentialHasServerSecret(credential: string | undefined): boolean { + return Boolean(credential && (credential.startsWith('xoxb-') || isEnvVarReference(credential))) +} + export const slackSelectors = { 'slack.channels': { key: 'slack.channels', contracts: [selectorContracts.slackChannelsSelectorContract], + serverResolvedContextFields: ['oauthCredential'], staleTime: SELECTOR_STALE, getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'slack.channels', - context.oauthCredential ?? 'none', + slackCredentialQueryKey(context.oauthCredential), ], - enabled: ({ context }) => Boolean(context.oauthCredential), + enabled: ({ context }) => + Boolean( + context.oauthCredential && + (!slackCredentialHasServerSecret(context.oauthCredential) || context.workflowId) + ), fetchList: async ({ context, signal }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'slack.channels') const data = await requestJson(selectorContracts.slackChannelsSelectorContract, { body: { credential: credentialId, - workflowId: context.workflowId, + ...(context.workflowId ? { workflowId: context.workflowId } : {}), }, signal, }) @@ -32,19 +47,24 @@ export const slackSelectors = { 'slack.users': { key: 'slack.users', contracts: [selectorContracts.slackUsersSelectorContract], + serverResolvedContextFields: ['oauthCredential'], staleTime: SELECTOR_STALE, getQueryKey: ({ context }: SelectorQueryArgs) => [ 'selectors', 'slack.users', - context.oauthCredential ?? 'none', + slackCredentialQueryKey(context.oauthCredential), ], - enabled: ({ context }) => Boolean(context.oauthCredential), + enabled: ({ context }) => + Boolean( + context.oauthCredential && + (!slackCredentialHasServerSecret(context.oauthCredential) || context.workflowId) + ), fetchList: async ({ context, signal }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'slack.users') const data = await requestJson(selectorContracts.slackUsersSelectorContract, { body: { credential: credentialId, - workflowId: context.workflowId, + ...(context.workflowId ? { workflowId: context.workflowId } : {}), }, signal, }) diff --git a/apps/sim/hooks/selectors/providers/slack/server-resolved-context.test.ts b/apps/sim/hooks/selectors/providers/slack/server-resolved-context.test.ts new file mode 100644 index 00000000000..c0bf1ffb9fb --- /dev/null +++ b/apps/sim/hooks/selectors/providers/slack/server-resolved-context.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ requestJson: vi.fn() })) + +vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.requestJson })) + +import { transformSlackSelectorContext } from '@/hooks/selectors/providers/slack/context' +import { slackSelectors } from '@/hooks/selectors/providers/slack/selectors' +import { getScopedSelectorQueryKey } from '@/hooks/selectors/use-selector-query' + +describe('Slack server-resolved selector context', () => { + beforeEach(() => vi.clearAllMocks()) + + it('propagates literal and referenced raw bot-token dependencies', () => { + for (const botToken of ['xoxb-literal-secret', '{{SLACK_BOT_TOKEN}}']) { + expect( + transformSlackSelectorContext( + { workflowId: 'workflow-1' }, + { authMethod: 'bot_token', botToken } + ).oauthCredential + ).toBe(botToken) + } + }) + + it('rejects runtime block references before enabling or requesting', () => { + const context = transformSlackSelectorContext( + { workflowId: 'workflow-1' }, + { authMethod: 'bot_token', botToken: '' } + ) + + expect(context.oauthCredential).toBeUndefined() + expect(slackSelectors['slack.channels'].enabled?.({ key: 'slack.channels', context })).toBe( + false + ) + expect(mocks.requestJson).not.toHaveBeenCalled() + }) + + it('supports workflowless OAuth/custom-bot credentials but requires workflows for direct secrets', () => { + const definition = slackSelectors['slack.channels'] + + expect( + definition.enabled?.({ + key: 'slack.channels', + context: { workspaceId: 'workspace-1', oauthCredential: 'credential-1' }, + }) + ).toBe(true) + expect( + definition.enabled?.({ + key: 'slack.channels', + context: { workspaceId: 'workspace-1', oauthCredential: 'xoxb-secret' }, + }) + ).toBe(false) + expect( + definition.enabled?.({ + key: 'slack.channels', + context: { workspaceId: 'workspace-1', oauthCredential: '{{SLACK_BOT_TOKEN}}' }, + }) + ).toBe(false) + }) + + it('forwards credential-backed requests and maps channel options', async () => { + mocks.requestJson.mockResolvedValue({ channels: [{ id: 'C111', name: 'general' }] }) + const definition = slackSelectors['slack.channels'] + const context = { workspaceId: 'workspace-1', oauthCredential: 'credential-1' } + + expect(await definition.fetchList!({ key: 'slack.channels', context })).toEqual([ + { id: 'C111', label: '#general' }, + ]) + expect(mocks.requestJson.mock.calls[0][1].body).toEqual({ credential: 'credential-1' }) + }) + + it('separates direct-token cache entries without putting plaintext in keys', () => { + const definition = slackSelectors['slack.channels'] + const firstKey = getScopedSelectorQueryKey(definition, { + key: 'slack.channels', + context: { + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + oauthCredential: 'xoxb-first-secret', + selectorCacheScope: 'revision-1', + }, + }) + const secondKey = getScopedSelectorQueryKey(definition, { + key: 'slack.channels', + context: { + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + oauthCredential: 'xoxb-second-secret', + selectorCacheScope: 'revision-2', + }, + }) + + expect(firstKey).not.toEqual(secondKey) + expect(JSON.stringify([firstKey, secondKey])).not.toContain('xoxb-first-secret') + expect(JSON.stringify([firstKey, secondKey])).not.toContain('xoxb-second-secret') + }) +}) diff --git a/apps/sim/lib/api/contracts/selectors/slack.ts b/apps/sim/lib/api/contracts/selectors/slack.ts index 70f272bda3f..7a8527e13f5 100644 --- a/apps/sim/lib/api/contracts/selectors/slack.ts +++ b/apps/sim/lib/api/contracts/selectors/slack.ts @@ -18,21 +18,23 @@ export const slackUsersBodySchema = credentialWorkflowBodySchema.extend({ userId: z.string().optional(), }) +export const slackSelectorCredentialBodySchema = credentialWorkflowBodySchema + export const slackChannelsSelectorContract = definePostSelector( '/api/tools/slack/channels', - credentialWorkflowBodySchema, + slackSelectorCredentialBodySchema, z.object({ channels: z.array(slackChannelSchema) }) ) export const slackUsersSelectorContract = definePostSelector( '/api/tools/slack/users', - credentialWorkflowBodySchema, + slackSelectorCredentialBodySchema, z.object({ users: z.array(slackUserSchema) }) ) export const slackUserSelectorContract = definePostSelector( '/api/tools/slack/users', - credentialWorkflowBodySchema.extend({ userId: z.string().min(1) }), + slackSelectorCredentialBodySchema.extend({ userId: z.string().min(1) }), z.object({ user: slackUserSchema }) ) diff --git a/apps/sim/lib/selectors/server/slack-credential.test.ts b/apps/sim/lib/selectors/server/slack-credential.test.ts new file mode 100644 index 00000000000..a4228adc856 --- /dev/null +++ b/apps/sim/lib/selectors/server/slack-credential.test.ts @@ -0,0 +1,197 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + providerMatches: vi.fn(), + refreshToken: vi.fn(), + resolveContext: vi.fn(), +})) + +vi.mock('@/lib/oauth/credential-service', () => ({ + refreshAccessTokenIfNeeded: mocks.refreshToken, +})) +vi.mock('@/lib/selectors/application/credential-provider', () => ({ + selectorCredentialMatchesService: mocks.providerMatches, +})) +vi.mock('@/lib/selectors/server/resolve-authorized-context', () => ({ + resolveAuthorizedSelectorContext: mocks.resolveContext, +})) + +import { resolveSlackSelectorCredential } from '@/lib/selectors/server/slack-credential' + +const principal = { kind: 'session', userId: 'viewer-1', sessionId: 'session-1' } as const + +describe('resolveSlackSelectorCredential', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.providerMatches.mockResolvedValue(true) + mocks.refreshToken.mockResolvedValue('xoxp-oauth-token') + mocks.resolveContext.mockResolvedValue({ + ok: true, + context: {}, + requesterUserId: 'viewer-1', + workspaceId: 'workspace-1', + credentialAccess: { + ok: true, + workspaceId: 'workspace-1', + credentialOwnerUserId: 'owner-1', + credentialType: 'oauth', + }, + }) + }) + + it.each([ + ['literal', 'xoxb-literal-secret'], + ['referenced', '{{SLACK_BOT_TOKEN}}'], + ])('workflow-authorizes a %s direct bot token', async (_label, credential) => { + mocks.resolveContext.mockResolvedValue({ + ok: true, + context: { credential: 'xoxb-resolved-secret' }, + requesterUserId: 'viewer-1', + workspaceId: 'workspace-1', + }) + + const result = await resolveSlackSelectorCredential(principal, { + credential, + workflowId: 'workflow-1', + requestId: 'request-1', + }) + + expect(result).toEqual({ + ok: true, + accessToken: 'xoxb-resolved-secret', + isBotToken: true, + }) + expect(mocks.resolveContext).toHaveBeenCalledWith(principal, { + workflowId: 'workflow-1', + context: { credential }, + }) + expect(mocks.providerMatches).not.toHaveBeenCalled() + expect(mocks.refreshToken).not.toHaveBeenCalled() + }) + + it('retains canonical credential authorization for workflowless OAuth connectors', async () => { + const result = await resolveSlackSelectorCredential(principal, { + credential: 'credential-1', + requestId: 'request-1', + }) + + expect(mocks.resolveContext).toHaveBeenCalledWith(principal, { + workflowId: undefined, + credentialId: 'credential-1', + context: {}, + }) + expect(mocks.providerMatches).toHaveBeenCalledWith({ + credentialId: 'credential-1', + credentialOwnerUserId: 'owner-1', + serviceId: 'slack', + }) + expect(mocks.refreshToken).toHaveBeenCalledWith('credential-1', 'owner-1', 'request-1') + expect(result).toMatchObject({ ok: true, accessToken: 'xoxp-oauth-token', isBotToken: false }) + }) + + it('supports provider-bound custom-bot service accounts', async () => { + mocks.resolveContext.mockResolvedValue({ + ok: true, + context: {}, + requesterUserId: 'viewer-1', + workspaceId: 'workspace-1', + credentialAccess: { + ok: true, + workspaceId: 'workspace-1', + credentialOwnerUserId: 'owner-1', + credentialType: 'service_account', + }, + }) + mocks.refreshToken.mockResolvedValue('xoxb-custom-bot-token') + + const result = await resolveSlackSelectorCredential(principal, { + credential: 'custom-bot-credential', + requestId: 'request-1', + }) + + expect(mocks.providerMatches).toHaveBeenCalledWith({ + credentialId: 'custom-bot-credential', + credentialOwnerUserId: 'owner-1', + serviceId: 'slack', + }) + expect(result).toMatchObject({ + ok: true, + accessToken: 'xoxb-custom-bot-token', + isBotToken: true, + }) + }) + + it('rejects a provider mismatch before token refresh or provider access', async () => { + mocks.providerMatches.mockResolvedValue(false) + + const result = await resolveSlackSelectorCredential(principal, { + credential: 'credential-1', + requestId: 'request-1', + }) + + expect(result).toEqual({ ok: false, status: 400, error: 'Select a Slack credential.' }) + expect(mocks.refreshToken).not.toHaveBeenCalled() + }) + + it('requires workflow scope for a direct bot token', async () => { + const result = await resolveSlackSelectorCredential(principal, { + credential: 'xoxb-literal-secret', + requestId: 'request-1', + }) + + expect(result).toEqual({ + ok: false, + status: 400, + error: 'Unable to resolve selector configuration', + }) + expect(mocks.resolveContext).not.toHaveBeenCalled() + }) + + it('does not reinterpret a referenced non-bot value as an OAuth credential id', async () => { + mocks.resolveContext.mockResolvedValue({ + ok: true, + context: { credential: 'not-a-bot-token' }, + requesterUserId: 'viewer-1', + workspaceId: 'workspace-1', + }) + + const result = await resolveSlackSelectorCredential(principal, { + credential: '{{SLACK_BOT_TOKEN}}', + workflowId: 'workflow-1', + requestId: 'request-1', + }) + + expect(result).toEqual({ + ok: false, + status: 400, + error: 'Unable to resolve selector configuration', + }) + expect(mocks.providerMatches).not.toHaveBeenCalled() + expect(mocks.refreshToken).not.toHaveBeenCalled() + }) + + it('does not attempt Slack access when a reference is inaccessible', async () => { + mocks.resolveContext.mockResolvedValue({ + ok: false, + status: 400, + error: 'Unable to resolve selector configuration', + }) + + const result = await resolveSlackSelectorCredential(principal, { + credential: '{{INACCESSIBLE_TOKEN}}', + workflowId: 'workflow-1', + requestId: 'request-1', + }) + + expect(result).toEqual({ + ok: false, + status: 400, + error: 'Unable to resolve selector configuration', + }) + expect(mocks.providerMatches).not.toHaveBeenCalled() + expect(mocks.refreshToken).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/selectors/server/slack-credential.ts b/apps/sim/lib/selectors/server/slack-credential.ts new file mode 100644 index 00000000000..a4c3ace70fa --- /dev/null +++ b/apps/sim/lib/selectors/server/slack-credential.ts @@ -0,0 +1,81 @@ +import type { Principal } from '@sim/auth/principal' +import type { CredentialAccessResult } from '@/lib/auth/credential-access' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' +import { selectorCredentialMatchesService } from '@/lib/selectors/application/credential-provider' +import { resolveAuthorizedSelectorContext } from '@/lib/selectors/server/resolve-authorized-context' +import { isEnvVarReference } from '@/executor/constants' + +const SAFE_RESOLUTION_ERROR = 'Unable to resolve selector configuration' + +export type SlackSelectorCredentialResult = + | { + ok: true + accessToken: string + isBotToken: boolean + credentialAccess?: CredentialAccessResult + } + | { ok: false; status: number; error: string } + +/** Resolves direct Slack bot-token references and credential ids behind one authorization path. */ +export async function resolveSlackSelectorCredential( + principal: Principal, + input: { credential: string; workflowId?: string; requestId: string } +): Promise { + let credential = input.credential + const isReferencedBotToken = isEnvVarReference(credential) + const isLiteralBotToken = credential.startsWith('xoxb-') + + if (isReferencedBotToken || isLiteralBotToken) { + if (!input.workflowId) { + return { ok: false, status: 400, error: SAFE_RESOLUTION_ERROR } + } + const resolution = await resolveAuthorizedSelectorContext(principal, { + workflowId: input.workflowId, + context: { credential }, + }) + if (!resolution.ok) return resolution + credential = resolution.context.credential + + if (credential.startsWith('xoxb-')) { + return { ok: true, accessToken: credential, isBotToken: true } + } + return { ok: false, status: 400, error: SAFE_RESOLUTION_ERROR } + } + + const resolution = await resolveAuthorizedSelectorContext(principal, { + workflowId: input.workflowId, + credentialId: credential, + context: {}, + }) + if (!resolution.ok) return resolution + + const credentialAccess = resolution.credentialAccess + if (!credentialAccess?.credentialOwnerUserId) { + return { ok: false, status: 403, error: 'Unauthorized' } + } + + const providerMatches = await selectorCredentialMatchesService({ + credentialId: credential, + credentialOwnerUserId: credentialAccess.credentialOwnerUserId, + serviceId: 'slack', + }) + if (!providerMatches) { + return { ok: false, status: 400, error: 'Select a Slack credential.' } + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + credentialAccess.credentialOwnerUserId, + input.requestId + ) + if (!accessToken) { + return { ok: false, status: 401, error: 'Could not retrieve access token' } + } + + return { + ok: true, + accessToken, + isBotToken: credentialAccess.credentialType !== 'oauth', + credentialAccess, + } +} From 5ad676793e1c3d2fa92d88f4b2fda8c0a0bae2a0 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 26 Aug 2026 15:19:32 -0700 Subject: [PATCH 2/4] fix(slack): preserve referenced selector credentials --- .../selectors/providers/slack/selectors.ts | 3 +- .../slack/server-resolved-context.test.ts | 45 +++---- .../selectors/server/slack-credential.test.ts | 120 ++++++++++++++---- .../lib/selectors/server/slack-credential.ts | 68 ++++++---- 4 files changed, 160 insertions(+), 76 deletions(-) diff --git a/apps/sim/hooks/selectors/providers/slack/selectors.ts b/apps/sim/hooks/selectors/providers/slack/selectors.ts index 3df993345ce..4cc7f37ecbb 100644 --- a/apps/sim/hooks/selectors/providers/slack/selectors.ts +++ b/apps/sim/hooks/selectors/providers/slack/selectors.ts @@ -5,8 +5,7 @@ import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/sh import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' function slackCredentialQueryKey(credential: string | undefined): string { - if (!credential) return 'none' - return credential.startsWith('xoxb-') ? 'direct-bot-token' : credential + return credential ? 'credential-present' : 'none' } function slackCredentialHasServerSecret(credential: string | undefined): boolean { diff --git a/apps/sim/hooks/selectors/providers/slack/server-resolved-context.test.ts b/apps/sim/hooks/selectors/providers/slack/server-resolved-context.test.ts index c0bf1ffb9fb..a513076ed6c 100644 --- a/apps/sim/hooks/selectors/providers/slack/server-resolved-context.test.ts +++ b/apps/sim/hooks/selectors/providers/slack/server-resolved-context.test.ts @@ -72,29 +72,30 @@ describe('Slack server-resolved selector context', () => { expect(mocks.requestJson.mock.calls[0][1].body).toEqual({ credential: 'credential-1' }) }) - it('separates direct-token cache entries without putting plaintext in keys', () => { + it('separates arbitrary credentials without putting their text in keys', () => { const definition = slackSelectors['slack.channels'] - const firstKey = getScopedSelectorQueryKey(definition, { - key: 'slack.channels', - context: { - workflowId: 'workflow-1', - workspaceId: 'workspace-1', - oauthCredential: 'xoxb-first-secret', - selectorCacheScope: 'revision-1', - }, - }) - const secondKey = getScopedSelectorQueryKey(definition, { - key: 'slack.channels', - context: { - workflowId: 'workflow-1', - workspaceId: 'workspace-1', - oauthCredential: 'xoxb-second-secret', - selectorCacheScope: 'revision-2', - }, - }) + const credentials = [ + 'credential-id', + 'xoxb-clean-secret', + ' xoxb-padded-secret', + '"xoxb-quoted-secret"', + '{{SLACK_SECRET_REFERENCE}}', + ] + const keys = credentials.map((oauthCredential, index) => + getScopedSelectorQueryKey(definition, { + key: 'slack.channels', + context: { + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + oauthCredential, + selectorCacheScope: `revision-${index}`, + }, + }) + ) - expect(firstKey).not.toEqual(secondKey) - expect(JSON.stringify([firstKey, secondKey])).not.toContain('xoxb-first-secret') - expect(JSON.stringify([firstKey, secondKey])).not.toContain('xoxb-second-secret') + expect(new Set(keys.map((key) => JSON.stringify(key))).size).toBe(credentials.length) + for (const credential of credentials) { + expect(JSON.stringify(keys)).not.toContain(credential) + } }) }) diff --git a/apps/sim/lib/selectors/server/slack-credential.test.ts b/apps/sim/lib/selectors/server/slack-credential.test.ts index a4228adc856..785f2acd0de 100644 --- a/apps/sim/lib/selectors/server/slack-credential.test.ts +++ b/apps/sim/lib/selectors/server/slack-credential.test.ts @@ -136,40 +136,108 @@ describe('resolveSlackSelectorCredential', () => { expect(mocks.refreshToken).not.toHaveBeenCalled() }) - it('requires workflow scope for a direct bot token', async () => { - const result = await resolveSlackSelectorCredential(principal, { - credential: 'xoxb-literal-secret', - requestId: 'request-1', - }) + it.each(['xoxb-literal-secret', '{{SLACK_CREDENTIAL}}'])( + 'requires workflow scope before resolving %s', + async (credential) => { + const result = await resolveSlackSelectorCredential(principal, { + credential, + requestId: 'request-1', + }) - expect(result).toEqual({ - ok: false, - status: 400, - error: 'Unable to resolve selector configuration', - }) - expect(mocks.resolveContext).not.toHaveBeenCalled() - }) + expect(result).toEqual({ + ok: false, + status: 400, + error: 'Unable to resolve selector configuration', + }) + expect(mocks.resolveContext).not.toHaveBeenCalled() + } + ) - it('does not reinterpret a referenced non-bot value as an OAuth credential id', async () => { - mocks.resolveContext.mockResolvedValue({ - ok: true, - context: { credential: 'not-a-bot-token' }, - requesterUserId: 'viewer-1', - workspaceId: 'workspace-1', - }) + it.each([ + ['OAuth', 'oauth', 'xoxp-oauth-token', false], + ['custom bot', 'service_account', 'xoxb-custom-bot-token', true], + ])( + 'authorizes a referenced %s credential after environment resolution', + async (_label, credentialType, accessToken, isBotToken) => { + mocks.resolveContext + .mockResolvedValueOnce({ + ok: true, + context: { credential: 'resolved-credential-id' }, + requesterUserId: 'viewer-1', + workspaceId: 'workspace-1', + }) + .mockResolvedValueOnce({ + ok: true, + context: {}, + requesterUserId: 'viewer-1', + workspaceId: 'workspace-1', + credentialAccess: { + ok: true, + workspaceId: 'workspace-1', + credentialOwnerUserId: 'owner-1', + credentialType, + }, + }) + mocks.refreshToken.mockResolvedValue(accessToken) + + const result = await resolveSlackSelectorCredential(principal, { + credential: '{{SLACK_CREDENTIAL_ID}}', + workflowId: 'workflow-1', + requestId: 'request-1', + }) + + expect(mocks.resolveContext).toHaveBeenNthCalledWith(1, principal, { + workflowId: 'workflow-1', + context: { credential: '{{SLACK_CREDENTIAL_ID}}' }, + }) + expect(mocks.resolveContext).toHaveBeenNthCalledWith(2, principal, { + workflowId: 'workflow-1', + credentialId: 'resolved-credential-id', + context: {}, + }) + expect(mocks.providerMatches).toHaveBeenCalledWith({ + credentialId: 'resolved-credential-id', + credentialOwnerUserId: 'owner-1', + serviceId: 'slack', + }) + expect(mocks.refreshToken).toHaveBeenCalledWith( + 'resolved-credential-id', + 'owner-1', + 'request-1' + ) + expect(result).toMatchObject({ ok: true, accessToken, isBotToken }) + } + ) + + it('provider-binds a referenced credential before token refresh', async () => { + mocks.resolveContext + .mockResolvedValueOnce({ + ok: true, + context: { credential: 'resolved-credential-id' }, + requesterUserId: 'viewer-1', + workspaceId: 'workspace-1', + }) + .mockResolvedValueOnce({ + ok: true, + context: {}, + requesterUserId: 'viewer-1', + workspaceId: 'workspace-1', + credentialAccess: { + ok: true, + workspaceId: 'workspace-1', + credentialOwnerUserId: 'owner-1', + credentialType: 'oauth', + }, + }) + mocks.providerMatches.mockResolvedValue(false) const result = await resolveSlackSelectorCredential(principal, { - credential: '{{SLACK_BOT_TOKEN}}', + credential: '{{SLACK_CREDENTIAL_ID}}', workflowId: 'workflow-1', requestId: 'request-1', }) - expect(result).toEqual({ - ok: false, - status: 400, - error: 'Unable to resolve selector configuration', - }) - expect(mocks.providerMatches).not.toHaveBeenCalled() + expect(result).toEqual({ ok: false, status: 400, error: 'Select a Slack credential.' }) expect(mocks.refreshToken).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/selectors/server/slack-credential.ts b/apps/sim/lib/selectors/server/slack-credential.ts index a4c3ace70fa..f4edda2979a 100644 --- a/apps/sim/lib/selectors/server/slack-credential.ts +++ b/apps/sim/lib/selectors/server/slack-credential.ts @@ -16,35 +16,13 @@ export type SlackSelectorCredentialResult = } | { ok: false; status: number; error: string } -/** Resolves direct Slack bot-token references and credential ids behind one authorization path. */ -export async function resolveSlackSelectorCredential( +async function resolveStoredSlackCredential( principal: Principal, input: { credential: string; workflowId?: string; requestId: string } ): Promise { - let credential = input.credential - const isReferencedBotToken = isEnvVarReference(credential) - const isLiteralBotToken = credential.startsWith('xoxb-') - - if (isReferencedBotToken || isLiteralBotToken) { - if (!input.workflowId) { - return { ok: false, status: 400, error: SAFE_RESOLUTION_ERROR } - } - const resolution = await resolveAuthorizedSelectorContext(principal, { - workflowId: input.workflowId, - context: { credential }, - }) - if (!resolution.ok) return resolution - credential = resolution.context.credential - - if (credential.startsWith('xoxb-')) { - return { ok: true, accessToken: credential, isBotToken: true } - } - return { ok: false, status: 400, error: SAFE_RESOLUTION_ERROR } - } - const resolution = await resolveAuthorizedSelectorContext(principal, { workflowId: input.workflowId, - credentialId: credential, + credentialId: input.credential, context: {}, }) if (!resolution.ok) return resolution @@ -55,7 +33,7 @@ export async function resolveSlackSelectorCredential( } const providerMatches = await selectorCredentialMatchesService({ - credentialId: credential, + credentialId: input.credential, credentialOwnerUserId: credentialAccess.credentialOwnerUserId, serviceId: 'slack', }) @@ -64,7 +42,7 @@ export async function resolveSlackSelectorCredential( } const accessToken = await refreshAccessTokenIfNeeded( - credential, + input.credential, credentialAccess.credentialOwnerUserId, input.requestId ) @@ -79,3 +57,41 @@ export async function resolveSlackSelectorCredential( credentialAccess, } } + +/** Resolves Slack references before selecting the direct-token or stored-credential path. */ +export async function resolveSlackSelectorCredential( + principal: Principal, + input: { credential: string; workflowId?: string; requestId: string } +): Promise { + let credential = input.credential + const isReferencedCredential = isEnvVarReference(credential) + const isLiteralBotToken = credential.startsWith('xoxb-') + + if (isReferencedCredential || isLiteralBotToken) { + if (!input.workflowId) { + return { ok: false, status: 400, error: SAFE_RESOLUTION_ERROR } + } + const resolution = await resolveAuthorizedSelectorContext(principal, { + workflowId: input.workflowId, + context: { credential }, + }) + if (!resolution.ok) return resolution + credential = resolution.context.credential + + if (credential.startsWith('xoxb-')) { + return { ok: true, accessToken: credential, isBotToken: true } + } + + return resolveStoredSlackCredential(principal, { + credential, + workflowId: input.workflowId, + requestId: input.requestId, + }) + } + + return resolveStoredSlackCredential(principal, { + credential, + workflowId: input.workflowId, + requestId: input.requestId, + }) +} From 5d9a783086e8d46dc678d45c379273c4bc15e42b Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 26 Aug 2026 17:05:29 -0700 Subject: [PATCH 3/4] fix(slack): preserve selector reauthorization responses --- .../sim/app/api/tools/slack/channels/route.ts | 5 +++- .../slack/server-resolved-selectors.test.ts | 26 +++++++++++++++++++ apps/sim/app/api/tools/slack/users/route.ts | 5 +++- apps/sim/lib/api/contracts/selectors/slack.ts | 1 + .../selectors/server/slack-credential.test.ts | 16 ++++++++++++ .../lib/selectors/server/slack-credential.ts | 9 +++++-- 6 files changed, 58 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/api/tools/slack/channels/route.ts b/apps/sim/app/api/tools/slack/channels/route.ts index 73afdf37184..0ae259c7589 100644 --- a/apps/sim/app/api/tools/slack/channels/route.ts +++ b/apps/sim/app/api/tools/slack/channels/route.ts @@ -68,7 +68,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) if (!resolvedCredential.ok) { return NextResponse.json( - { error: resolvedCredential.error }, + { + error: resolvedCredential.error, + ...(resolvedCredential.authRequired ? { authRequired: true } : {}), + }, { status: resolvedCredential.status } ) } diff --git a/apps/sim/app/api/tools/slack/server-resolved-selectors.test.ts b/apps/sim/app/api/tools/slack/server-resolved-selectors.test.ts index 7346b20f35a..68d43c1f882 100644 --- a/apps/sim/app/api/tools/slack/server-resolved-selectors.test.ts +++ b/apps/sim/app/api/tools/slack/server-resolved-selectors.test.ts @@ -74,6 +74,32 @@ describe('server-resolved Slack selectors', () => { expect(providerFetch).not.toHaveBeenCalled() }) + it.each([ + ['channels', listChannels, '/api/tools/slack/channels'], + ['users', listUsers, '/api/tools/slack/users'], + ])( + 'preserves the reauthorization marker from the %s credential resolver', + async (_name, handler, path) => { + mocks.resolveSlackCredential.mockResolvedValue({ + ok: false, + status: 401, + error: 'Could not retrieve access token', + authRequired: true, + }) + const providerFetch = vi.fn() + vi.stubGlobal('fetch', providerFetch) + + const response = await handler(request(path, { credential: 'credential-1' })) + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + error: 'Could not retrieve access token', + authRequired: true, + }) + expect(providerFetch).not.toHaveBeenCalled() + } + ) + it('supports a workflowless stored credential through the route', async () => { vi.stubGlobal( 'fetch', diff --git a/apps/sim/app/api/tools/slack/users/route.ts b/apps/sim/app/api/tools/slack/users/route.ts index 84782a0bc06..7ee01e3e125 100644 --- a/apps/sim/app/api/tools/slack/users/route.ts +++ b/apps/sim/app/api/tools/slack/users/route.ts @@ -57,7 +57,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) if (!resolvedCredential.ok) { return NextResponse.json( - { error: resolvedCredential.error }, + { + error: resolvedCredential.error, + ...(resolvedCredential.authRequired ? { authRequired: true } : {}), + }, { status: resolvedCredential.status } ) } diff --git a/apps/sim/lib/api/contracts/selectors/slack.ts b/apps/sim/lib/api/contracts/selectors/slack.ts index 7a8527e13f5..eb1df42ceae 100644 --- a/apps/sim/lib/api/contracts/selectors/slack.ts +++ b/apps/sim/lib/api/contracts/selectors/slack.ts @@ -19,6 +19,7 @@ export const slackUsersBodySchema = credentialWorkflowBodySchema.extend({ }) export const slackSelectorCredentialBodySchema = credentialWorkflowBodySchema +export type SlackSelectorCredentialBody = z.input export const slackChannelsSelectorContract = definePostSelector( '/api/tools/slack/channels', diff --git a/apps/sim/lib/selectors/server/slack-credential.test.ts b/apps/sim/lib/selectors/server/slack-credential.test.ts index 785f2acd0de..37bf8a9bc7c 100644 --- a/apps/sim/lib/selectors/server/slack-credential.test.ts +++ b/apps/sim/lib/selectors/server/slack-credential.test.ts @@ -136,6 +136,22 @@ describe('resolveSlackSelectorCredential', () => { expect(mocks.refreshToken).not.toHaveBeenCalled() }) + it('marks a stored credential for reauthorization when token refresh returns nothing', async () => { + mocks.refreshToken.mockResolvedValue(null) + + const result = await resolveSlackSelectorCredential(principal, { + credential: 'credential-1', + requestId: 'request-1', + }) + + expect(result).toEqual({ + ok: false, + status: 401, + error: 'Could not retrieve access token', + authRequired: true, + }) + }) + it.each(['xoxb-literal-secret', '{{SLACK_CREDENTIAL}}'])( 'requires workflow scope before resolving %s', async (credential) => { diff --git a/apps/sim/lib/selectors/server/slack-credential.ts b/apps/sim/lib/selectors/server/slack-credential.ts index f4edda2979a..e8e99213488 100644 --- a/apps/sim/lib/selectors/server/slack-credential.ts +++ b/apps/sim/lib/selectors/server/slack-credential.ts @@ -14,7 +14,7 @@ export type SlackSelectorCredentialResult = isBotToken: boolean credentialAccess?: CredentialAccessResult } - | { ok: false; status: number; error: string } + | { ok: false; status: number; error: string; authRequired?: true } async function resolveStoredSlackCredential( principal: Principal, @@ -47,7 +47,12 @@ async function resolveStoredSlackCredential( input.requestId ) if (!accessToken) { - return { ok: false, status: 401, error: 'Could not retrieve access token' } + return { + ok: false, + status: 401, + error: 'Could not retrieve access token', + authRequired: true, + } } return { From fcd414e017765877cca6204a2b1e924abd88dd6a Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 26 Aug 2026 18:00:32 -0700 Subject: [PATCH 4/4] fix(slack): surface selector authentication failures --- .../slack/server-resolved-selectors.test.ts | 34 +++++++++++++++++++ apps/sim/app/api/tools/slack/users/route.ts | 20 +++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/tools/slack/server-resolved-selectors.test.ts b/apps/sim/app/api/tools/slack/server-resolved-selectors.test.ts index 68d43c1f882..2098c2e6e67 100644 --- a/apps/sim/app/api/tools/slack/server-resolved-selectors.test.ts +++ b/apps/sim/app/api/tools/slack/server-resolved-selectors.test.ts @@ -218,4 +218,38 @@ describe('server-resolved Slack selectors', () => { users: [{ id: 'U111', name: 'bill', real_name: 'Bill' }], }) }) + + it.each([ + [ + 'stored credential', + { + ok: true, + accessToken: 'xoxb-stored', + isBotToken: false, + credentialAccess: { credentialType: 'oauth' }, + }, + { error: 'Slack authentication failed', authRequired: true }, + ], + [ + 'direct token', + { ok: true, accessToken: 'xoxb-direct', isBotToken: true }, + { error: 'Slack authentication failed' }, + ], + ])('sanitizes invalid_auth for a %s', async (_name, credentialResult, expectedBody) => { + mocks.resolveSlackCredential.mockResolvedValue(credentialResult) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ ok: false, error: 'invalid_auth' })) + ) + + const response = await listUsers( + request('/api/tools/slack/users', { + credential: 'credential-or-token', + workflowId: 'workflow-1', + }) + ) + + expect(response.status).toBe(401) + expect(await response.json()).toEqual(expectedBody) + }) }) diff --git a/apps/sim/app/api/tools/slack/users/route.ts b/apps/sim/app/api/tools/slack/users/route.ts index 7ee01e3e125..82fc91dd71e 100644 --- a/apps/sim/app/api/tools/slack/users/route.ts +++ b/apps/sim/app/api/tools/slack/users/route.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { slackUsersListOrDetailContract } from '@/lib/api/contracts/selectors/slack' import { parseRequest } from '@/lib/api/server' @@ -29,6 +30,9 @@ interface SlackUsersResult { } export const POST = withRouteHandler(async (request: NextRequest) => { + let providerRequestStarted = false + let reauthorizationAvailable = false + try { const requestId = generateRequestId() const authentication = await authenticateSelectorRequest(request) @@ -64,7 +68,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: resolvedCredential.status } ) } - const { accessToken, isBotToken } = resolvedCredential + const { accessToken, isBotToken, credentialAccess } = resolvedCredential + reauthorizationAvailable = credentialAccess !== undefined + providerRequestStarted = true if (userId) { const userData = await fetchSlackUser(accessToken, userId) @@ -95,7 +101,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { tokenType: isBotToken ? 'bot_token' : 'oauth', }) return NextResponse.json({ users }) - } catch { + } catch (error) { + if (providerRequestStarted && getErrorMessage(error) === 'invalid_auth') { + logger.warn('Slack rejected selector authentication') + return NextResponse.json( + { + error: 'Slack authentication failed', + ...(reauthorizationAvailable ? { authRequired: true } : {}), + }, + { status: 401 } + ) + } logger.error('Error processing Slack users request') return NextResponse.json({ error: 'Failed to retrieve Slack users' }, { status: 500 }) }