Skip to content
Closed
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
77 changes: 28 additions & 49 deletions apps/sim/app/api/tools/slack/channels/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -49,68 +49,50 @@ 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')
return parsed.response
}
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,
...(resolvedCredential.authRequired ? { authRequired: true } : {}),
},
{ 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
Expand Down Expand Up @@ -225,12 +207,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 })
}
})

Expand Down
255 changes: 255 additions & 0 deletions apps/sim/app/api/tools/slack/server-resolved-selectors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
/**
* @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.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',
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' }],
})
})

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)
})
})
Loading
Loading