Skip to content

Commit 6ea53c0

Browse files
fix(slack): allow installation before organization setup (#7900)
* fix(slack): allow installation before organization setup * fix(slack): keep installation guidance neutral
1 parent ff25be7 commit 6ea53c0

11 files changed

Lines changed: 361 additions & 6 deletions

File tree

‎apps/docs/content/docs/search/slack.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ When **Install Sim Search** is available, use the official app:
1717
3. If needed, invite teammates through **Settings → Members → Invite** using their Slack email addresses. App installation does not add people to the Sim organization.
1818
4. Each member opens **Integrations**, selects **Connect** for Slack, and authorizes their own account using the same email as their verified Sim account.
1919

20+
You can also install the official app directly from Slack without choosing a Sim organization or signing in to Sim. When ready, an admin follows the steps above and authorizes the already-installed app for their organization. Sim saves the connection at that point; until then, the bot cannot answer searches. Personal source connections remain separate.
21+
2022
Members use the admin's settings without selecting channels again. Public and private channels are included by default; DMs are opt-in. The bot installation alone does not enable Slack as a source or authorize access to members' messages.
2123

2224
<Image className="mx-auto h-auto w-full max-w-md" src="/static/search/slack-shared-install.png" alt="Install Sim Search using the shared Slack app" width={515} height={171} />
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/** @vitest-environment node */
2+
import { authMockFns, dbChainMockFns } from '@sim/testing'
3+
import { NextRequest } from 'next/server'
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { OrchestrationError } from '@/lib/core/orchestration/types'
6+
7+
const m = vi.hoisted(() => ({
8+
authenticate: vi.fn(),
9+
complete: vi.fn(),
10+
rate: vi.fn(),
11+
}))
12+
vi.mock('@/lib/slack-search/public-install-auth', () => ({
13+
authenticateSlackPublicInstallation: m.authenticate,
14+
}))
15+
vi.mock('@/lib/knowledge/application/slack-search/setup', () => ({
16+
completeSlackSearchSetup: { execute: m.complete },
17+
}))
18+
vi.mock('@/lib/core/rate-limiter', async (importOriginal) => ({
19+
...(await importOriginal<typeof import('@/lib/core/rate-limiter')>()),
20+
enforceIpRateLimit: m.rate,
21+
enforceUserRateLimit: m.rate,
22+
}))
23+
vi.mock('@/lib/core/utils/urls', () => ({
24+
getBaseUrl: () => 'https://www.sim.ai',
25+
SITE_URL: 'https://www.sim.ai',
26+
}))
27+
28+
import { GET, HEAD } from '@/app/api/knowledge/slack/oauth/callback/route'
29+
30+
const request = (query: string) =>
31+
new NextRequest(`https://www.sim.ai/api/knowledge/slack/oauth/callback?${query}`)
32+
beforeEach(() => {
33+
vi.clearAllMocks()
34+
authMockFns.mockGetSession.mockResolvedValue({
35+
user: { id: 'admin' },
36+
session: { id: 'session' },
37+
})
38+
m.rate.mockResolvedValue(null)
39+
m.authenticate.mockResolvedValue({ teamId: 'T1' })
40+
m.complete.mockResolvedValue({ organizationId: 'org1' })
41+
})
42+
describe('Slack OAuth callback', () => {
43+
it.each(['code=code', 'state=&code=code'])(
44+
'accepts Slack-initiated install without Sim login: %s',
45+
async (query) => {
46+
authMockFns.mockGetSession.mockResolvedValue(null)
47+
const response = await GET(request(query))
48+
expect(response.status).toBe(303)
49+
expect(response.headers.get('location')).toBe('https://www.sim.ai/slack-search/install/T1')
50+
expect(response.headers.get('cache-control')).toBe('no-store')
51+
expect(response.headers.get('referrer-policy')).toBe('no-referrer')
52+
expect(authMockFns.mockGetSession).not.toHaveBeenCalled()
53+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
54+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
55+
expect(m.complete).not.toHaveBeenCalled()
56+
}
57+
)
58+
it('does not attach a public grant to an existing browser session', async () => {
59+
await GET(request('code=code&organizationId=attacker'))
60+
expect(authMockFns.mockGetSession).not.toHaveBeenCalled()
61+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
62+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
63+
expect(m.complete).not.toHaveBeenCalled()
64+
})
65+
it('keeps org-initiated installs on the existing session/state path', async () => {
66+
const response = await GET(request('state=state&code=code'))
67+
expect(response.status).toBe(303)
68+
expect(response.headers.get('location')).toBe(
69+
'https://www.sim.ai/o/org1/settings/search-slack?slackSetup=complete'
70+
)
71+
expect(m.complete).toHaveBeenCalledWith(
72+
expect.objectContaining({
73+
principal: { kind: 'session', userId: 'admin', sessionId: 'session' },
74+
input: { state: 'state', code: 'code', error: undefined },
75+
})
76+
)
77+
expect(m.authenticate).not.toHaveBeenCalled()
78+
})
79+
it('never falls back to public install on an invalid nonempty state', async () => {
80+
m.complete.mockRejectedValueOnce(new OrchestrationError('validation', 'Expired state'))
81+
expect((await GET(request('state=expired&code=code'))).status).toBe(400)
82+
expect(m.authenticate).not.toHaveBeenCalled()
83+
})
84+
it('still requires a Sim session for an org-bound state', async () => {
85+
authMockFns.mockGetSession.mockResolvedValue(null)
86+
expect((await GET(request('state=state&code=code'))).status).toBe(401)
87+
expect(m.authenticate).not.toHaveBeenCalled()
88+
expect(m.complete).not.toHaveBeenCalled()
89+
})
90+
it.each(['error=access_denied', '', 'state=&state=other&code=code', 'code=a&code=b'])(
91+
'rejects ambiguous or denied callbacks: %s',
92+
async (query) => {
93+
expect((await GET(request(query))).status).toBe(400)
94+
expect(m.authenticate).not.toHaveBeenCalled()
95+
expect(m.complete).not.toHaveBeenCalled()
96+
}
97+
)
98+
it('does not consume codes on HEAD requests or after rate limiting', async () => {
99+
expect((await HEAD(request('code=code'))).status).toBe(405)
100+
m.rate.mockResolvedValue(new Response(null, { status: 429 }))
101+
expect((await GET(request('code=code'))).status).toBe(429)
102+
expect(m.authenticate).not.toHaveBeenCalled()
103+
})
104+
})

‎apps/sim/app/api/knowledge/slack/oauth/callback/route.ts‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,50 @@ import {
77
internalRateLimits,
88
internalSessionAuth,
99
} from '@/lib/api/server/routes'
10+
import { OrchestrationError } from '@/lib/core/orchestration/types'
11+
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
1012
import { getBaseUrl } from '@/lib/core/utils/urls'
1113
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1214
import { completeSlackSearchSetup } from '@/lib/knowledge/application/slack-search/setup'
1315
import { organizationRoutes } from '@/lib/navigation/paths'
16+
import { slackSearchInstallPath } from '@/lib/slack-search/install-link'
17+
import { authenticateSlackPublicInstallation } from '@/lib/slack-search/public-install-auth'
1418

1519
/** OAuth is a redirect protocol; protected configuration remains in the application use case. */
1620
export const GET = withRouteHandler(async (request) => {
1721
try {
22+
const limited = await enforceIpRateLimit('slack-search-oauth-callback', request)
23+
if (limited) return limited
24+
const parsed = await parseRequest(
25+
slackSearchOAuthCallbackContract,
26+
request,
27+
{},
28+
{
29+
rejectDuplicateQueryValues: true,
30+
}
31+
)
32+
if (!parsed.success) return parsed.response
33+
const { state, code, error } = parsed.data.query
34+
if (!state) {
35+
if (error || !code)
36+
throw new OrchestrationError(
37+
'validation',
38+
'Slack installation was not authorized. Install the app again.'
39+
)
40+
const { teamId } = await authenticateSlackPublicInstallation(code)
41+
return NextResponse.redirect(new URL(slackSearchInstallPath(teamId), getBaseUrl()), {
42+
status: 303,
43+
headers: { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' },
44+
})
45+
}
1846
const principal = await internalSessionAuth.authenticate()
1947
const rateResponse = await internalRateLimits
2048
.user({ bucketName: 'slack-search-settings' })
2149
.enforce(request, principal)
2250
if (rateResponse) return rateResponse
23-
const parsed = await parseRequest(slackSearchOAuthCallbackContract, request, {})
24-
if (!parsed.success) return parsed.response
2551
const result = await completeSlackSearchSetup.execute({
2652
principal,
27-
input: parsed.data.query,
53+
input: { state, code, error },
2854
request,
2955
})
3056
const url = new URL(
@@ -44,3 +70,6 @@ export const GET = withRouteHandler(async (request) => {
4470
throw error
4571
}
4672
})
73+
74+
/** Link previews must not consume a single-use OAuth code. */
75+
export const HEAD = withRouteHandler(async () => new NextResponse(null, { status: 405 }))
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/** @vitest-environment node */
2+
import type { ComponentProps, ReactNode } from 'react'
3+
import { authMockFns } from '@sim/testing'
4+
import { renderToStaticMarkup } from 'react-dom/server'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const m = vi.hoisted(() => ({ app: vi.fn() }))
8+
vi.mock('@sim/emcn', () => ({
9+
ChipLink: ({ children, href }: ComponentProps<'a'>) => <a href={href}>{children}</a>,
10+
}))
11+
vi.mock('@/app/(auth)/components', () => ({
12+
AuthShell: ({ children }: { children: ReactNode }) => <main>{children}</main>,
13+
}))
14+
vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: true }))
15+
vi.mock('@/lib/slack-search/shared-app-env', () => ({
16+
getSharedSlackSearchAppConfiguration: m.app,
17+
}))
18+
vi.mock('next/navigation', () => ({
19+
notFound: () => {
20+
throw new Error('Not found')
21+
},
22+
}))
23+
24+
import SlackInstallPage from '@/app/slack-search/install/[teamId]/page'
25+
26+
beforeEach(() => {
27+
vi.clearAllMocks()
28+
m.app.mockReturnValue({ id: 'A1' })
29+
authMockFns.mockGetSession.mockResolvedValue(null)
30+
})
31+
describe('Slack-initiated install entry', () => {
32+
it('shows setup guidance without asserting installation or requiring sign-in', async () => {
33+
const markup = renderToStaticMarkup(
34+
await SlackInstallPage({ params: Promise.resolve({ teamId: 'T1' }) })
35+
)
36+
expect(markup).toContain('Sim Search in Slack')
37+
expect(markup).not.toContain('is installed')
38+
expect(markup).toContain('https://slack.com/app_redirect?app=A1&amp;team=T1')
39+
expect(markup).toContain('href="/home"')
40+
expect(markup).not.toContain('/login')
41+
expect(authMockFns.mockGetSession).not.toHaveBeenCalled()
42+
})
43+
it('does not infer an organization from an existing Sim session', async () => {
44+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user' } })
45+
const markup = renderToStaticMarkup(
46+
await SlackInstallPage({ params: Promise.resolve({ teamId: 'T1' }) })
47+
)
48+
expect(markup).toContain('connect this workspace later')
49+
expect(authMockFns.mockGetSession).not.toHaveBeenCalled()
50+
})
51+
it('rejects malformed workspace hints and unavailable apps before reading a session', async () => {
52+
await expect(
53+
SlackInstallPage({ params: Promise.resolve({ teamId: 'https://attacker.test' }) })
54+
).rejects.toThrow('Not found')
55+
m.app.mockReturnValue(null)
56+
await expect(SlackInstallPage({ params: Promise.resolve({ teamId: 'T1' }) })).rejects.toThrow(
57+
'Not found'
58+
)
59+
expect(authMockFns.mockGetSession).not.toHaveBeenCalled()
60+
})
61+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { ChipLink } from '@sim/emcn'
2+
import type { Metadata } from 'next'
3+
import { notFound } from 'next/navigation'
4+
import { isHosted } from '@/lib/core/config/env-flags'
5+
import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
6+
import { getSharedSlackSearchAppConfiguration } from '@/lib/slack-search/shared-app-env'
7+
import { AuthShell } from '@/app/(auth)/components'
8+
9+
export const metadata: Metadata = {
10+
title: 'Sim Search in Slack',
11+
robots: { index: false, follow: false },
12+
referrer: 'no-referrer',
13+
}
14+
15+
interface SlackInstallPageProps {
16+
params: Promise<{ teamId: string }>
17+
}
18+
19+
export default async function SlackInstallPage({ params }: SlackInstallPageProps) {
20+
const { teamId } = await params
21+
const app = isHosted ? getSharedSlackSearchAppConfiguration() : null
22+
if (!/^T[A-Z0-9]{1,199}$/.test(teamId) || !app) notFound()
23+
const slackUrl = new URL('https://slack.com/app_redirect')
24+
slackUrl.search = new URLSearchParams({ app: app.id, team: teamId }).toString()
25+
return (
26+
<AuthShell>
27+
<div className='flex flex-col gap-5'>
28+
<h1 className='text-2xl'>Sim Search in Slack</h1>
29+
<p className='text-[var(--text-muted)] text-sm'>
30+
To start searching, an admin can connect this workspace later from Settings → Sim Search
31+
in Slack in their Sim organization.
32+
</p>
33+
<div className='flex gap-2'>
34+
<ChipLink variant='primary' href={slackUrl.href}>
35+
Open Slack
36+
</ChipLink>
37+
<ChipLink href={APP_ENTRY_PATH}>Open Sim</ChipLink>
38+
</div>
39+
</div>
40+
</AuthShell>
41+
)
42+
}

‎apps/sim/lib/api/contracts/knowledge/slack.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export const startSlackSearchOAuthContract = defineRouteContract({
9292
})
9393

9494
export const slackSearchOAuthCallbackQuerySchema = z.object({
95-
state: z.string().min(1).max(200),
95+
state: z.string().max(200).optional(),
9696
code: z.string().min(1).max(2000).optional(),
9797
error: z.string().min(1).max(200).optional(),
9898
})

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,19 @@ beforeEach(() => {
2828
fetchMock.mockReset().mockResolvedValue(Response.json(grant))
2929
})
3030
describe('Slack bot OAuth exchange', () => {
31+
it('uses the registered default callback for Slack-initiated installs and discards personal grants', async () => {
32+
fetchMock.mockResolvedValueOnce(
33+
Response.json({ ...grant, authed_user: { access_token: 'personal-token' } })
34+
)
35+
expect(
36+
await exchangeSlackBotAuthorization({
37+
clientId: 'client',
38+
clientSecret: 'secret',
39+
code: 'code',
40+
})
41+
).toEqual(grant)
42+
expect(fetchMock.mock.calls[0][1].body.has('redirect_uri')).toBe(false)
43+
})
3144
it('exchanges a code with the same callback and client authentication', async () => {
3245
expect(await exchangeSlackBotAuthorization(input)).toEqual(grant)
3346
const [url, request] = fetchMock.mock.calls[0]

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,18 @@ export async function exchangeSlackBotAuthorization(input: {
2323
clientId: string
2424
clientSecret: string
2525
code: string
26-
redirectUri: string
26+
redirectUri?: string
2727
}) {
2828
const response = await fetch('https://slack.com/api/oauth.v2.access', {
2929
method: 'POST',
3030
headers: {
3131
Authorization: `Basic ${Buffer.from(`${input.clientId}:${input.clientSecret}`).toString('base64')}`,
3232
'Content-Type': 'application/x-www-form-urlencoded',
3333
},
34-
body: new URLSearchParams({ code: input.code, redirect_uri: input.redirectUri }),
34+
body: new URLSearchParams({
35+
code: input.code,
36+
...(input.redirectUri ? { redirect_uri: input.redirectUri } : {}),
37+
}),
3538
signal: AbortSignal.timeout(10_000),
3639
})
3740
const value = await readResponseJsonWithLimit<unknown>(response, {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/** The team is a selection hint; linking requires a fresh admin-bound Slack authorization. */
2+
export function slackSearchInstallPath(teamId: string) {
3+
if (!/^T[A-Z0-9]{1,199}$/.test(teamId)) throw new Error('Invalid Slack workspace ID')
4+
return `/slack-search/install/${teamId}`
5+
}

0 commit comments

Comments
 (0)