Skip to content

Commit 8ef42ee

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(slack): resolve direct-token selector contexts server-side
1 parent 17726cb commit 8ef42ee

12 files changed

Lines changed: 671 additions & 111 deletions

File tree

apps/sim/app/api/tools/slack/channels/route.ts

Lines changed: 25 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import { eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { slackChannelsSelectorContract } from '@/lib/api/contracts/selectors/slack'
77
import { parseRequest } from '@/lib/api/server'
8-
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
98
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
109
import { generateRequestId } from '@/lib/core/utils/request'
1110
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12-
import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service'
11+
import { authenticateSelectorRequest } from '@/lib/selectors/server/resolve-authorized-context'
12+
import { resolveSlackSelectorCredential } from '@/lib/selectors/server/slack-credential'
1313

1414
export const dynamic = 'force-dynamic'
1515

@@ -49,68 +49,47 @@ function parseScopedSlackUserId(accountId: string): string | null {
4949
export const POST = withRouteHandler(async (request: NextRequest) => {
5050
try {
5151
const requestId = generateRequestId()
52+
const authentication = await authenticateSelectorRequest(request)
53+
if (!authentication.ok) {
54+
return NextResponse.json({ error: authentication.error }, { status: authentication.status })
55+
}
5256
const parsed = await parseRequest(slackChannelsSelectorContract, request, {})
5357
if (!parsed.success) {
5458
logger.error('Missing credential in request')
5559
return parsed.response
5660
}
5761
const { credential, workflowId } = parsed.data.body
5862

59-
let accessToken: string
60-
let isBotToken = false
6163
let scopedUserId: string | null = null
62-
63-
if (credential.startsWith('xoxb-')) {
64-
accessToken = credential
65-
isBotToken = true
66-
logger.info('Using direct bot token for Slack API')
67-
} else {
68-
const authz = await authorizeCredentialUse(request, {
69-
credentialId: credential,
70-
workflowId: workflowId ?? undefined,
71-
})
72-
if (!authz.ok || !authz.credentialOwnerUserId) {
73-
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
74-
}
75-
const resolvedToken = await refreshAccessTokenIfNeeded(
76-
credential,
77-
authz.credentialOwnerUserId,
78-
requestId
64+
const resolvedCredential = await resolveSlackSelectorCredential(authentication.principal, {
65+
credential,
66+
workflowId,
67+
requestId,
68+
})
69+
if (!resolvedCredential.ok) {
70+
return NextResponse.json(
71+
{ error: resolvedCredential.error },
72+
{ status: resolvedCredential.status }
7973
)
80-
if (!resolvedToken) {
81-
logger.error('Failed to get access token', {
82-
credentialId: credential,
83-
userId: authz.credentialOwnerUserId,
84-
})
85-
return NextResponse.json(
86-
{
87-
error: 'Could not retrieve access token',
88-
authRequired: true,
89-
},
90-
{ status: 401 }
91-
)
92-
}
93-
accessToken = resolvedToken
74+
}
75+
const { accessToken, isBotToken, credentialAccess } = resolvedCredential
9476

77+
if (!isBotToken && credentialAccess) {
9578
// resolvedCredentialId is an account.id only for OAuth credentials
9679
// (the service_account path returns a credential.id).
97-
if (authz.credentialType === 'oauth' && authz.resolvedCredentialId) {
80+
if (credentialAccess.credentialType === 'oauth' && credentialAccess.resolvedCredentialId) {
9881
logger.info('Using OAuth token for Slack API')
9982
const [accountRow] = await db
10083
.select({ accountId: account.accountId })
10184
.from(account)
102-
.where(eq(account.id, authz.resolvedCredentialId))
85+
.where(eq(account.id, credentialAccess.resolvedCredentialId))
10386
.limit(1)
10487
if (accountRow) {
10588
scopedUserId = parseScopedSlackUserId(accountRow.accountId)
10689
}
107-
} else {
108-
// A custom-bot service_account credential resolves to a bot token with
109-
// no scoped user; treat it like a direct bot token so the private ->
110-
// public channel fallback applies.
111-
isBotToken = true
112-
logger.info('Using custom bot token for Slack API')
11390
}
91+
} else {
92+
logger.info('Using bot token for Slack API')
11493
}
11594

11695
let data: SlackConversationsResult
@@ -225,12 +204,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
225204
userScoped: !!scopedUserId,
226205
})
227206
return NextResponse.json({ channels })
228-
} catch (error) {
229-
logger.error('Error processing Slack channels request:', error)
230-
return NextResponse.json(
231-
{ error: 'Failed to retrieve Slack channels', details: (error as Error).message },
232-
{ status: 500 }
233-
)
207+
} catch {
208+
logger.error('Error processing Slack channels request')
209+
return NextResponse.json({ error: 'Failed to retrieve Slack channels' }, { status: 500 })
234210
}
235211
})
236212

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({
8+
authenticate: vi.fn(),
9+
resolveSlackCredential: vi.fn(),
10+
}))
11+
12+
vi.mock('@/lib/selectors/server/resolve-authorized-context', () => ({
13+
authenticateSelectorRequest: mocks.authenticate,
14+
}))
15+
16+
vi.mock('@/lib/selectors/server/slack-credential', () => ({
17+
resolveSlackSelectorCredential: mocks.resolveSlackCredential,
18+
}))
19+
20+
import { POST as listChannels } from '@/app/api/tools/slack/channels/route'
21+
import { POST as listUsers } from '@/app/api/tools/slack/users/route'
22+
23+
function request(path: string, body: unknown) {
24+
return createMockRequest('POST', body, {}, `http://localhost:3000${path}`)
25+
}
26+
27+
describe('server-resolved Slack selectors', () => {
28+
beforeEach(() => {
29+
vi.clearAllMocks()
30+
mocks.authenticate.mockResolvedValue({
31+
ok: true,
32+
principal: { kind: 'session', userId: 'viewer-1', sessionId: 'session-1' },
33+
})
34+
mocks.resolveSlackCredential.mockResolvedValue({
35+
ok: true,
36+
accessToken: 'xoxb-resolved',
37+
isBotToken: true,
38+
})
39+
})
40+
41+
it('authenticates before parsing malformed requests', async () => {
42+
mocks.authenticate.mockResolvedValue({ ok: false, status: 401, error: 'Unauthorized' })
43+
44+
const response = await listChannels(request('/api/tools/slack/channels', {}))
45+
46+
expect(response.status).toBe(401)
47+
expect(mocks.resolveSlackCredential).not.toHaveBeenCalled()
48+
})
49+
50+
it('passes raw references to the authorized credential resolver and short-circuits denial', async () => {
51+
mocks.resolveSlackCredential.mockResolvedValue({
52+
ok: false,
53+
status: 400,
54+
error: 'Unable to resolve selector configuration',
55+
})
56+
const providerFetch = vi.fn()
57+
vi.stubGlobal('fetch', providerFetch)
58+
59+
const response = await listChannels(
60+
request('/api/tools/slack/channels', {
61+
credential: '{{INACCESSIBLE_TOKEN}}',
62+
workflowId: 'workflow-1',
63+
})
64+
)
65+
66+
expect(response.status).toBe(400)
67+
expect(mocks.resolveSlackCredential).toHaveBeenCalledWith(
68+
expect.anything(),
69+
expect.objectContaining({
70+
credential: '{{INACCESSIBLE_TOKEN}}',
71+
workflowId: 'workflow-1',
72+
})
73+
)
74+
expect(providerFetch).not.toHaveBeenCalled()
75+
})
76+
77+
it('supports a workflowless stored credential through the route', async () => {
78+
vi.stubGlobal(
79+
'fetch',
80+
vi.fn().mockResolvedValue(
81+
Response.json({
82+
ok: true,
83+
channels: [{ id: 'C111', name: 'general', is_private: false, is_archived: false }],
84+
response_metadata: { next_cursor: '' },
85+
})
86+
)
87+
)
88+
89+
const response = await listChannels(
90+
request('/api/tools/slack/channels', { credential: 'credential-1' })
91+
)
92+
93+
expect(response.status).toBe(200)
94+
expect(mocks.resolveSlackCredential).toHaveBeenCalledWith(
95+
expect.anything(),
96+
expect.objectContaining({ credential: 'credential-1', workflowId: undefined })
97+
)
98+
})
99+
100+
it('paginates channels and preserves bot-token private-channel filtering', async () => {
101+
vi.stubGlobal(
102+
'fetch',
103+
vi
104+
.fn()
105+
.mockResolvedValueOnce(
106+
Response.json({
107+
ok: true,
108+
channels: [
109+
{
110+
id: 'C111',
111+
name: 'general',
112+
is_private: false,
113+
is_archived: false,
114+
is_member: false,
115+
},
116+
{
117+
id: 'G222',
118+
name: 'private-member',
119+
is_private: true,
120+
is_archived: false,
121+
is_member: true,
122+
},
123+
{
124+
id: 'G333',
125+
name: 'private-not-member',
126+
is_private: true,
127+
is_archived: false,
128+
is_member: false,
129+
},
130+
],
131+
response_metadata: { next_cursor: 'page-2' },
132+
})
133+
)
134+
.mockResolvedValueOnce(
135+
Response.json({
136+
ok: true,
137+
channels: [
138+
{
139+
id: 'C444',
140+
name: 'announcements',
141+
is_private: false,
142+
is_archived: false,
143+
is_member: false,
144+
},
145+
],
146+
response_metadata: { next_cursor: '' },
147+
})
148+
)
149+
)
150+
151+
const response = await listChannels(
152+
request('/api/tools/slack/channels', {
153+
credential: 'xoxb-literal-secret',
154+
workflowId: 'workflow-1',
155+
})
156+
)
157+
158+
expect(await response.json()).toEqual({
159+
channels: [
160+
{ id: 'C111', name: 'general', isPrivate: false },
161+
{ id: 'G222', name: 'private-member', isPrivate: true },
162+
{ id: 'C444', name: 'announcements', isPrivate: false },
163+
],
164+
})
165+
expect(String(vi.mocked(fetch).mock.calls[1][0])).toContain('cursor=page-2')
166+
})
167+
168+
it('maps users and filters deleted users and bots', async () => {
169+
vi.stubGlobal(
170+
'fetch',
171+
vi.fn().mockResolvedValue(
172+
Response.json({
173+
ok: true,
174+
members: [
175+
{ id: 'U111', name: 'bill', real_name: 'Bill', deleted: false, is_bot: false },
176+
{ id: 'U222', name: 'bot', real_name: 'Bot', deleted: false, is_bot: true },
177+
{ id: 'U333', name: 'old', real_name: 'Old', deleted: true, is_bot: false },
178+
],
179+
response_metadata: { next_cursor: '' },
180+
})
181+
)
182+
)
183+
184+
const response = await listUsers(
185+
request('/api/tools/slack/users', {
186+
credential: '{{SLACK_BOT_TOKEN}}',
187+
workflowId: 'workflow-1',
188+
})
189+
)
190+
191+
expect(await response.json()).toEqual({
192+
users: [{ id: 'U111', name: 'bill', real_name: 'Bill' }],
193+
})
194+
})
195+
})

0 commit comments

Comments
 (0)