Skip to content

Commit a2b9234

Browse files
fix(slack): clean up unsuccessful shared app grants
1 parent 7fff762 commit a2b9234

5 files changed

Lines changed: 468 additions & 213 deletions

File tree

apps/sim/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,6 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
254254
# MISTRAL_OCR_QUOTA_GROUPS={"<64-character lowercase key fingerprint>":"organization-id"}
255255

256256
# Official Sim Search Slack app (optional; requires existing Search access)
257-
# Register the company app with scripts/register-platform-slack-app.ts --search.
257+
# Register the company app with bun scripts/register-platform-slack-app.ts <APP_ID> --search.
258258
# SLACK_SEARCH_APP_ID=
259259
# SLACK_SEARCH_SHARED_APP=false # Off-production fallback for the global slack-search-shared-app flag

apps/sim/lib/internal/slack/oauth.test.ts

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
/** @vitest-environment node */
22
import { beforeEach, describe, expect, it, vi } from 'vitest'
3-
import { exchangeSlackBotAuthorization } from '@/lib/internal/slack/oauth'
3+
import {
4+
exchangeSlackBotAuthorization,
5+
revokeSlackBotAuthorization,
6+
validateSlackBotAuthorization,
7+
} from '@/lib/internal/slack/oauth'
48
import { SLACK_SEARCH_SCOPES } from '@/lib/slack-search/constants'
59

610
const fetchMock = vi.fn()
@@ -11,9 +15,9 @@ const input = {
1115
redirectUri: 'https://sim.test/api/knowledge/slack/oauth/callback',
1216
}
1317
const grant = {
14-
ok: true,
18+
ok: true as const,
1519
app_id: 'A1',
16-
token_type: 'bot',
20+
token_type: 'bot' as const,
1721
access_token: 'test-bot-token',
1822
bot_user_id: 'UBOT',
1923
scope: SLACK_SEARCH_SCOPES.join(','),
@@ -34,19 +38,48 @@ describe('Slack bot OAuth exchange', () => {
3438
expect(request.body.get('redirect_uri')).toBe(input.redirectUri)
3539
expect(request.body.get('code')).toBe('code')
3640
})
41+
it.each([{ token_type: 'user' }, { ok: false, error: 'invalid_client_id' }])(
42+
'rejects incompatible or unsuccessful grants: %j',
43+
async (change) => {
44+
fetchMock.mockResolvedValueOnce(Response.json({ ...grant, ...change }))
45+
await expect(exchangeSlackBotAuthorization(input)).rejects.toThrow()
46+
}
47+
)
48+
it('does not expose provider credentials in error messages', async () => {
49+
fetchMock.mockResolvedValueOnce(Response.json({ ok: false, error: 'SECRET-DO-NOT-LOG' }))
50+
await expect(exchangeSlackBotAuthorization(input)).rejects.toThrow('Slack authorization failed')
51+
})
52+
})
53+
54+
describe('Slack bot grant policy and cleanup', () => {
3755
it.each([
38-
{ token_type: 'user' },
3956
{ is_enterprise_install: true },
4057
{ refresh_token: 'refresh' },
4158
{ expires_in: 3600 },
4259
{ scope: 'chat:write' },
43-
{ ok: false, error: 'invalid_client_id' },
44-
])('rejects incompatible or unsuccessful grants: %j', async (change) => {
45-
fetchMock.mockResolvedValueOnce(Response.json({ ...grant, ...change }))
46-
await expect(exchangeSlackBotAuthorization(input)).rejects.toThrow()
60+
])('rejects unsupported grants after the caller takes ownership: %j', (change) => {
61+
expect(() => validateSlackBotAuthorization({ ...grant, ...change })).toThrow()
4762
})
48-
it('does not expose provider credentials in error messages', async () => {
63+
it('accepts the existing indexing bot scope policy', () => {
64+
expect(() => validateSlackBotAuthorization(grant)).not.toThrow()
65+
})
66+
it('requires the additional command scope for shared installs', () => {
67+
expect(() =>
68+
validateSlackBotAuthorization(grant, [...SLACK_SEARCH_SCOPES, 'commands'])
69+
).toThrow('commands')
70+
})
71+
it('revokes an unused token through Slack with a bounded request', async () => {
72+
fetchMock.mockResolvedValueOnce(Response.json({ ok: true, revoked: true }))
73+
await revokeSlackBotAuthorization('unused-token')
74+
const [url, request] = fetchMock.mock.calls[0]
75+
expect(String(url)).toContain('/api/auth.revoke')
76+
expect(request.headers.Authorization).toBe('Bearer unused-token')
77+
expect(request.signal).toBeDefined()
78+
})
79+
it('fails visibly when Slack does not confirm revocation', async () => {
4980
fetchMock.mockResolvedValueOnce(Response.json({ ok: false, error: 'SECRET-DO-NOT-LOG' }))
50-
await expect(exchangeSlackBotAuthorization(input)).rejects.toThrow('Slack authorization failed')
81+
await expect(revokeSlackBotAuthorization('unused-token')).rejects.toThrow(
82+
'Slack could not revoke'
83+
)
5184
})
5285
})

apps/sim/lib/internal/slack/oauth.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer'
22
import { z } from 'zod'
33
import { OrchestrationError } from '@/lib/core/orchestration/types'
44
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
5+
import { requestSlackApi } from '@/lib/internal/slack/client'
56
import { SLACK_SEARCH_SCOPES } from '@/lib/slack-search/constants'
67

78
const botGrantSchema = z.object({
@@ -44,19 +45,35 @@ export async function exchangeSlackBotAuthorization(input: {
4445
'Slack authorization failed. Check the client credentials and install the app again.'
4546
)
4647
}
47-
const grant = parsed.data
48-
if (grant.is_enterprise_install || grant.refresh_token || grant.expires_in) {
48+
return parsed.data
49+
}
50+
51+
/** Runs after exchange so the application can clean up an issued grant if policy rejects it. */
52+
export function validateSlackBotAuthorization(
53+
grant: z.infer<typeof botGrantSchema>,
54+
requiredScopes: readonly string[] = SLACK_SEARCH_SCOPES
55+
) {
56+
if (grant.is_enterprise_install || grant.refresh_token || grant.expires_in)
4957
throw new OrchestrationError(
5058
'validation',
5159
'Install the app in one workspace with token rotation disabled.'
5260
)
53-
}
5461
const scopes = grant.scope.split(',').map((scope) => scope.trim())
55-
const missing = SLACK_SEARCH_SCOPES.filter((scope) => !scopes.includes(scope))
62+
const missing = requiredScopes.filter((scope) => !scopes.includes(scope))
5663
if (missing.length)
5764
throw new OrchestrationError(
5865
'validation',
5966
`Reinstall the app with these scopes: ${missing.join(', ')}`
6067
)
61-
return grant
68+
}
69+
70+
/** Revokes an unused bot grant after failed setup without logging provider credentials. */
71+
export async function revokeSlackBotAuthorization(accessToken: string) {
72+
const response = await requestSlackApi({
73+
accessToken,
74+
method: 'auth.revoke',
75+
signal: AbortSignal.timeout(10_000),
76+
})
77+
if (response.status !== 200 || response.data.ok !== true || response.data.revoked !== true)
78+
throw new OrchestrationError('validation', 'Slack could not revoke the unused setup token')
6279
}

apps/sim/lib/knowledge/application/slack-search/setup.test.ts

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ const m = vi.hoisted(() => ({
1414
audit: vi.fn(),
1515
baseUrl: vi.fn(),
1616
shared: vi.fn(),
17+
revoke: vi.fn(),
18+
validateGrant: vi.fn(),
19+
ensureGroup: vi.fn(),
1720
}))
1821
vi.mock('@/lib/slack-search/shared-app', () => ({ readSharedSlackSearchApp: m.shared }))
1922
vi.mock('@sim/audit', () => ({
@@ -45,7 +48,12 @@ vi.mock('@/lib/slack-search/oauth-state', () => ({
4548
consumeSlackSearchOAuthAttempt: m.consume,
4649
storeSlackSearchOAuthAttempt: m.store,
4750
}))
48-
vi.mock('@/lib/internal/slack/oauth', () => ({ exchangeSlackBotAuthorization: m.exchange }))
51+
vi.mock('@/lib/internal/slack/oauth', () => ({
52+
exchangeSlackBotAuthorization: m.exchange,
53+
revokeSlackBotAuthorization: m.revoke,
54+
validateSlackBotAuthorization: m.validateGrant,
55+
}))
56+
vi.mock('@/lib/credential-groups/service', () => ({ ensureWorkspaceAccountsGroup: m.ensureGroup }))
4957
vi.mock('@/lib/credential-groups/organization-slack-app', () => ({
5058
loadOrganizationSlackMemberApps: async () => [],
5159
adoptOrganizationSlackMemberApp: vi.fn(),
@@ -87,6 +95,9 @@ const complete = () =>
8795
beforeEach(() => {
8896
vi.clearAllMocks()
8997
m.shared.mockResolvedValue(null)
98+
m.revoke.mockResolvedValue(undefined)
99+
m.validateGrant.mockReset()
100+
m.ensureGroup.mockResolvedValue({ id: 'accounts' })
90101
m.baseUrl.mockReturnValue('https://sim.test')
91102
m.membership.mockResolvedValue([{ role: 'admin' }])
92103
m.rows.mockReset().mockResolvedValue([])
@@ -286,3 +297,130 @@ it('rejects a shared-app callback if the global configuration was disabled or ro
286297
await expect(complete()).rejects.toThrow()
287298
expect(m.exchange).not.toHaveBeenCalled()
288299
})
300+
301+
describe('shared app completion', () => {
302+
const sharedApp = { id: 'A1', revision: 'shared-revision', kind: 'shared', organizationId: null }
303+
beforeEach(() => {
304+
m.shared.mockResolvedValue(sharedApp)
305+
m.consume.mockResolvedValue({
306+
...attempt,
307+
sharedApp: { id: sharedApp.id, revision: sharedApp.revision },
308+
})
309+
})
310+
311+
it('commits the personal app configuration, bot credential and installation in one transaction', async () => {
312+
m.rows
313+
.mockResolvedValueOnce([sharedApp])
314+
.mockResolvedValueOnce([])
315+
.mockResolvedValueOnce([])
316+
.mockResolvedValueOnce([
317+
{ id: 'accounts', options: [], encryptedProviderConfiguration: null },
318+
])
319+
await expect(complete()).resolves.toEqual({ organizationId: 'org1' })
320+
expect(db.transaction).toHaveBeenCalledOnce()
321+
expect(m.ensureGroup).toHaveBeenCalledWith(
322+
{ kind: 'organization', organizationId: 'org1' },
323+
'admin',
324+
undefined,
325+
expect.objectContaining({ insert: expect.any(Function) })
326+
)
327+
const group = m.set.mock.calls[0][0]
328+
expect(group.options).toEqual([
329+
expect.objectContaining({
330+
provider: 'slack',
331+
authorizationAppId: 'slack:A1:T1',
332+
status: 'active',
333+
requiredScopes: expect.arrayContaining([
334+
'channels:history',
335+
'groups:history',
336+
'im:history',
337+
'mpim:history',
338+
'users:read.email',
339+
]),
340+
}),
341+
])
342+
const configuration = JSON.parse(
343+
group.encryptedProviderConfiguration.slice('encrypted:'.length)
344+
)
345+
expect(configuration.slack).toMatchObject({
346+
source: 'slack_app',
347+
appId: 'A1',
348+
teamId: 'T1',
349+
scopes: group.options[0].requiredScopes,
350+
})
351+
expect(configuration.slack).not.toHaveProperty('clientSecret')
352+
const rows = m.values.mock.calls.map(([value]) => value)
353+
expect(rows).toHaveLength(2)
354+
expect(rows[0]).toMatchObject({
355+
organizationId: 'org1',
356+
workspaceId: null,
357+
type: 'service_account',
358+
slackAppId: 'A1',
359+
})
360+
expect(rows[1]).toMatchObject({
361+
organizationId: 'org1',
362+
credentialId: rows[0].id,
363+
slackAppId: 'A1',
364+
appId: 'A1',
365+
teamId: 'T1',
366+
enabled: true,
367+
})
368+
expect(m.verify).toHaveBeenCalledTimes(2)
369+
expect(m.revoke).not.toHaveBeenCalled()
370+
expect(m.audit).toHaveBeenCalledOnce()
371+
})
372+
373+
it('revokes an unused shared bot grant after a conflicting workspace binding', async () => {
374+
m.rows
375+
.mockResolvedValueOnce([sharedApp])
376+
.mockResolvedValueOnce([])
377+
.mockResolvedValueOnce([{ id: 'other-app' }])
378+
await expect(complete()).rejects.toThrow('already has an active Search installation')
379+
expect(m.revoke).toHaveBeenCalledWith('bot-token')
380+
expect(m.values).not.toHaveBeenCalled()
381+
expect(m.audit).not.toHaveBeenCalled()
382+
})
383+
384+
it('revokes an unused shared grant after a database write fails', async () => {
385+
m.rows
386+
.mockResolvedValueOnce([sharedApp])
387+
.mockResolvedValueOnce([])
388+
.mockResolvedValueOnce([])
389+
.mockResolvedValueOnce([
390+
{ id: 'accounts', options: [], encryptedProviderConfiguration: null },
391+
])
392+
m.values.mockImplementationOnce(() => {
393+
throw new Error('write failed')
394+
})
395+
await expect(complete()).rejects.toThrow('write failed')
396+
expect(m.revoke).toHaveBeenCalledWith('bot-token')
397+
expect(m.audit).not.toHaveBeenCalled()
398+
})
399+
400+
it('never revokes a bot with an existing installation when the initiating admin loses access', async () => {
401+
m.verify.mockImplementationOnce(async () => {
402+
m.membership.mockResolvedValue([{ role: 'member' }])
403+
return identity
404+
})
405+
m.rows.mockResolvedValueOnce([{ id: 'existing-installation' }])
406+
await expect(complete()).rejects.toThrow('administrator')
407+
expect(m.revoke).not.toHaveBeenCalled()
408+
expect(m.values).not.toHaveBeenCalled()
409+
})
410+
411+
it('revokes a shared grant rejected by scope or token-rotation policy', async () => {
412+
m.validateGrant.mockImplementationOnce(() => {
413+
throw new Error('unsupported grant')
414+
})
415+
await expect(complete()).rejects.toThrow('unsupported grant')
416+
expect(m.revoke).toHaveBeenCalledWith('bot-token')
417+
expect(m.values).not.toHaveBeenCalled()
418+
})
419+
420+
it('surfaces cleanup failure with a concrete recovery step', async () => {
421+
m.verify.mockRejectedValueOnce(new Error('invalid bot'))
422+
m.revoke.mockRejectedValueOnce(new Error('provider failed'))
423+
await expect(complete()).rejects.toThrow('Remove the unused app in Slack before retrying')
424+
expect(m.audit).not.toHaveBeenCalled()
425+
})
426+
})

0 commit comments

Comments
 (0)