Skip to content

Commit 939feb5

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(slack): resolve direct-token selector contexts server-side
1 parent ad5a672 commit 939feb5

13 files changed

Lines changed: 677 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: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
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('paginates channels and preserves bot-token private-channel filtering', async () => {
78+
vi.stubGlobal(
79+
'fetch',
80+
vi
81+
.fn()
82+
.mockResolvedValueOnce(
83+
Response.json({
84+
ok: true,
85+
channels: [
86+
{
87+
id: 'C111',
88+
name: 'general',
89+
is_private: false,
90+
is_archived: false,
91+
is_member: false,
92+
},
93+
{
94+
id: 'G222',
95+
name: 'private-member',
96+
is_private: true,
97+
is_archived: false,
98+
is_member: true,
99+
},
100+
{
101+
id: 'G333',
102+
name: 'private-not-member',
103+
is_private: true,
104+
is_archived: false,
105+
is_member: false,
106+
},
107+
],
108+
response_metadata: { next_cursor: 'page-2' },
109+
})
110+
)
111+
.mockResolvedValueOnce(
112+
Response.json({
113+
ok: true,
114+
channels: [
115+
{
116+
id: 'C444',
117+
name: 'announcements',
118+
is_private: false,
119+
is_archived: false,
120+
is_member: false,
121+
},
122+
],
123+
response_metadata: { next_cursor: '' },
124+
})
125+
)
126+
)
127+
128+
const response = await listChannels(
129+
request('/api/tools/slack/channels', {
130+
credential: 'xoxb-literal-secret',
131+
workflowId: 'workflow-1',
132+
})
133+
)
134+
135+
expect(await response.json()).toEqual({
136+
channels: [
137+
{ id: 'C111', name: 'general', isPrivate: false },
138+
{ id: 'G222', name: 'private-member', isPrivate: true },
139+
{ id: 'C444', name: 'announcements', isPrivate: false },
140+
],
141+
})
142+
expect(String(vi.mocked(fetch).mock.calls[1][0])).toContain('cursor=page-2')
143+
})
144+
145+
it('maps users and filters deleted users and bots', async () => {
146+
vi.stubGlobal(
147+
'fetch',
148+
vi.fn().mockResolvedValue(
149+
Response.json({
150+
ok: true,
151+
members: [
152+
{ id: 'U111', name: 'bill', real_name: 'Bill', deleted: false, is_bot: false },
153+
{ id: 'U222', name: 'bot', real_name: 'Bot', deleted: false, is_bot: true },
154+
{ id: 'U333', name: 'old', real_name: 'Old', deleted: true, is_bot: false },
155+
],
156+
response_metadata: { next_cursor: '' },
157+
})
158+
)
159+
)
160+
161+
const response = await listUsers(
162+
request('/api/tools/slack/users', {
163+
credential: '{{SLACK_BOT_TOKEN}}',
164+
workflowId: 'workflow-1',
165+
})
166+
)
167+
168+
expect(await response.json()).toEqual({
169+
users: [{ id: 'U111', name: 'bill', real_name: 'Bill' }],
170+
})
171+
})
172+
})

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

Lines changed: 19 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { slackUsersListOrDetailContract } from '@/lib/api/contracts/selectors/slack'
44
import { parseRequest } from '@/lib/api/server'
5-
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
65
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
76
import { generateRequestId } from '@/lib/core/utils/request'
87
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9-
import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service'
8+
import { authenticateSelectorRequest } from '@/lib/selectors/server/resolve-authorized-context'
9+
import { resolveSlackSelectorCredential } from '@/lib/selectors/server/slack-credential'
1010

1111
export const dynamic = 'force-dynamic'
1212

@@ -31,6 +31,10 @@ interface SlackUsersResult {
3131
export const POST = withRouteHandler(async (request: NextRequest) => {
3232
try {
3333
const requestId = generateRequestId()
34+
const authentication = await authenticateSelectorRequest(request)
35+
if (!authentication.ok) {
36+
return NextResponse.json({ error: authentication.error }, { status: authentication.status })
37+
}
3438
const parsed = await parseRequest(slackUsersListOrDetailContract, request, {})
3539
if (!parsed.success) {
3640
logger.error('Missing credential in request')
@@ -46,41 +50,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4650
}
4751
}
4852

49-
let accessToken: string
50-
const isBotToken = credential.startsWith('xoxb-')
51-
52-
if (isBotToken) {
53-
accessToken = credential
54-
logger.info('Using direct bot token for Slack API')
55-
} else {
56-
const authz = await authorizeCredentialUse(request, {
57-
credentialId: credential,
58-
workflowId,
59-
})
60-
if (!authz.ok || !authz.credentialOwnerUserId) {
61-
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
62-
}
63-
const resolvedToken = await refreshAccessTokenIfNeeded(
64-
credential,
65-
authz.credentialOwnerUserId,
66-
requestId
53+
const resolvedCredential = await resolveSlackSelectorCredential(authentication.principal, {
54+
credential,
55+
workflowId,
56+
requestId,
57+
})
58+
if (!resolvedCredential.ok) {
59+
return NextResponse.json(
60+
{ error: resolvedCredential.error },
61+
{ status: resolvedCredential.status }
6762
)
68-
if (!resolvedToken) {
69-
logger.error('Failed to get access token', {
70-
credentialId: credential,
71-
userId: authz.credentialOwnerUserId,
72-
})
73-
return NextResponse.json(
74-
{
75-
error: 'Could not retrieve access token',
76-
authRequired: true,
77-
},
78-
{ status: 401 }
79-
)
80-
}
81-
accessToken = resolvedToken
82-
logger.info('Using OAuth token for Slack API')
8363
}
64+
const { accessToken, isBotToken } = resolvedCredential
8465

8566
if (userId) {
8667
const userData = await fetchSlackUser(accessToken, userId)
@@ -111,12 +92,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
11192
tokenType: isBotToken ? 'bot_token' : 'oauth',
11293
})
11394
return NextResponse.json({ users })
114-
} catch (error) {
115-
logger.error('Error processing Slack users request:', error)
116-
return NextResponse.json(
117-
{ error: 'Failed to retrieve Slack users', details: (error as Error).message },
118-
{ status: 500 }
119-
)
95+
} catch {
96+
logger.error('Error processing Slack users request')
97+
return NextResponse.json({ error: 'Failed to retrieve Slack users' }, { status: 500 })
12098
}
12199
})
122100

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-input/selector-input.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,11 @@ export function SelectorInput({
4242
selectorContext: autoContext,
4343
allowSearch,
4444
disabled: selectorDisabled,
45-
dependencyValues,
45+
rawDependencyValues,
4646
} = useSelectorSetup(blockId, subBlock, { disabled, isPreview, previewContextValues })
4747

4848
const selectorContext = overrides?.transformContext
49-
? overrides.transformContext(autoContext, dependencyValues)
49+
? overrides.transformContext(autoContext, rawDependencyValues)
5050
: autoContext
5151

5252
useEffect(() => {

0 commit comments

Comments
 (0)