Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/docs/content/docs/search/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ When **Install Sim Search** is available, use the official app:
3. If needed, invite teammates through **Settings → Members → Invite** using their Slack email addresses. App installation does not add people to the Sim organization.
4. Each member opens **Integrations**, selects **Connect** for Slack, and authorizes their own account using the same email as their verified Sim account.

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.

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.

<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} />
Expand Down
104 changes: 104 additions & 0 deletions apps/sim/app/api/knowledge/slack/oauth/callback/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/** @vitest-environment node */
import { authMockFns, dbChainMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OrchestrationError } from '@/lib/core/orchestration/types'

const m = vi.hoisted(() => ({
authenticate: vi.fn(),
complete: vi.fn(),
rate: vi.fn(),
}))
vi.mock('@/lib/slack-search/public-install-auth', () => ({
authenticateSlackPublicInstallation: m.authenticate,
}))
vi.mock('@/lib/knowledge/application/slack-search/setup', () => ({
completeSlackSearchSetup: { execute: m.complete },
}))
vi.mock('@/lib/core/rate-limiter', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/core/rate-limiter')>()),
enforceIpRateLimit: m.rate,
enforceUserRateLimit: m.rate,
}))
vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: () => 'https://www.sim.ai',
SITE_URL: 'https://www.sim.ai',
}))

import { GET, HEAD } from '@/app/api/knowledge/slack/oauth/callback/route'

const request = (query: string) =>
new NextRequest(`https://www.sim.ai/api/knowledge/slack/oauth/callback?${query}`)
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'admin' },
session: { id: 'session' },
})
m.rate.mockResolvedValue(null)
m.authenticate.mockResolvedValue({ teamId: 'T1' })
m.complete.mockResolvedValue({ organizationId: 'org1' })
})
describe('Slack OAuth callback', () => {
it.each(['code=code', 'state=&code=code'])(
'accepts Slack-initiated install without Sim login: %s',
async (query) => {
authMockFns.mockGetSession.mockResolvedValue(null)
const response = await GET(request(query))
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('https://www.sim.ai/slack-search/install/T1')
expect(response.headers.get('cache-control')).toBe('no-store')
expect(response.headers.get('referrer-policy')).toBe('no-referrer')
expect(authMockFns.mockGetSession).not.toHaveBeenCalled()
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
expect(dbChainMockFns.update).not.toHaveBeenCalled()
expect(m.complete).not.toHaveBeenCalled()
}
)
it('does not attach a public grant to an existing browser session', async () => {
await GET(request('code=code&organizationId=attacker'))
expect(authMockFns.mockGetSession).not.toHaveBeenCalled()
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
expect(dbChainMockFns.update).not.toHaveBeenCalled()
expect(m.complete).not.toHaveBeenCalled()
})
it('keeps org-initiated installs on the existing session/state path', async () => {
const response = await GET(request('state=state&code=code'))
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe(
'https://www.sim.ai/o/org1/settings/search-slack?slackSetup=complete'
)
expect(m.complete).toHaveBeenCalledWith(
expect.objectContaining({
principal: { kind: 'session', userId: 'admin', sessionId: 'session' },
input: { state: 'state', code: 'code', error: undefined },
})
)
expect(m.authenticate).not.toHaveBeenCalled()
})
it('never falls back to public install on an invalid nonempty state', async () => {
m.complete.mockRejectedValueOnce(new OrchestrationError('validation', 'Expired state'))
expect((await GET(request('state=expired&code=code'))).status).toBe(400)
expect(m.authenticate).not.toHaveBeenCalled()
})
it('still requires a Sim session for an org-bound state', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
expect((await GET(request('state=state&code=code'))).status).toBe(401)
expect(m.authenticate).not.toHaveBeenCalled()
expect(m.complete).not.toHaveBeenCalled()
})
it.each(['error=access_denied', '', 'state=&state=other&code=code', 'code=a&code=b'])(
'rejects ambiguous or denied callbacks: %s',
async (query) => {
expect((await GET(request(query))).status).toBe(400)
expect(m.authenticate).not.toHaveBeenCalled()
expect(m.complete).not.toHaveBeenCalled()
}
)
it('does not consume codes on HEAD requests or after rate limiting', async () => {
expect((await HEAD(request('code=code'))).status).toBe(405)
m.rate.mockResolvedValue(new Response(null, { status: 429 }))
expect((await GET(request('code=code'))).status).toBe(429)
expect(m.authenticate).not.toHaveBeenCalled()
})
})
35 changes: 32 additions & 3 deletions apps/sim/app/api/knowledge/slack/oauth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,50 @@ import {
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { completeSlackSearchSetup } from '@/lib/knowledge/application/slack-search/setup'
import { organizationRoutes } from '@/lib/navigation/paths'
import { slackSearchInstallPath } from '@/lib/slack-search/install-link'
import { authenticateSlackPublicInstallation } from '@/lib/slack-search/public-install-auth'

/** OAuth is a redirect protocol; protected configuration remains in the application use case. */
export const GET = withRouteHandler(async (request) => {
try {
const limited = await enforceIpRateLimit('slack-search-oauth-callback', request)
if (limited) return limited
const parsed = await parseRequest(
slackSearchOAuthCallbackContract,
request,
{},
{
rejectDuplicateQueryValues: true,
}
)
if (!parsed.success) return parsed.response
const { state, code, error } = parsed.data.query
if (!state) {
if (error || !code)
throw new OrchestrationError(
'validation',
'Slack installation was not authorized. Install the app again.'
)
const { teamId } = await authenticateSlackPublicInstallation(code)
return NextResponse.redirect(new URL(slackSearchInstallPath(teamId), getBaseUrl()), {
status: 303,
headers: { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' },
})
}
const principal = await internalSessionAuth.authenticate()
const rateResponse = await internalRateLimits
.user({ bucketName: 'slack-search-settings' })
.enforce(request, principal)
if (rateResponse) return rateResponse
const parsed = await parseRequest(slackSearchOAuthCallbackContract, request, {})
if (!parsed.success) return parsed.response
const result = await completeSlackSearchSetup.execute({
principal,
input: parsed.data.query,
input: { state, code, error },
request,
})
const url = new URL(
Expand All @@ -44,3 +70,6 @@ export const GET = withRouteHandler(async (request) => {
throw error
}
})

/** Link previews must not consume a single-use OAuth code. */
export const HEAD = withRouteHandler(async () => new NextResponse(null, { status: 405 }))
61 changes: 61 additions & 0 deletions apps/sim/app/slack-search/install/[teamId]/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/** @vitest-environment node */
import type { ComponentProps, ReactNode } from 'react'
import { authMockFns } from '@sim/testing'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const m = vi.hoisted(() => ({ app: vi.fn() }))
vi.mock('@sim/emcn', () => ({
ChipLink: ({ children, href }: ComponentProps<'a'>) => <a href={href}>{children}</a>,
}))
vi.mock('@/app/(auth)/components', () => ({
AuthShell: ({ children }: { children: ReactNode }) => <main>{children}</main>,
}))
vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: true }))
vi.mock('@/lib/slack-search/shared-app-env', () => ({
getSharedSlackSearchAppConfiguration: m.app,
}))
vi.mock('next/navigation', () => ({
notFound: () => {
throw new Error('Not found')
},
}))

import SlackInstallPage from '@/app/slack-search/install/[teamId]/page'

beforeEach(() => {
vi.clearAllMocks()
m.app.mockReturnValue({ id: 'A1' })
authMockFns.mockGetSession.mockResolvedValue(null)
})
describe('Slack-initiated install entry', () => {
it('shows setup guidance without asserting installation or requiring sign-in', async () => {
const markup = renderToStaticMarkup(
await SlackInstallPage({ params: Promise.resolve({ teamId: 'T1' }) })
)
expect(markup).toContain('Sim Search in Slack')
expect(markup).not.toContain('is installed')
expect(markup).toContain('https://slack.com/app_redirect?app=A1&amp;team=T1')
expect(markup).toContain('href="/home"')
expect(markup).not.toContain('/login')
expect(authMockFns.mockGetSession).not.toHaveBeenCalled()
})
it('does not infer an organization from an existing Sim session', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user' } })
const markup = renderToStaticMarkup(
await SlackInstallPage({ params: Promise.resolve({ teamId: 'T1' }) })
)
expect(markup).toContain('connect this workspace later')
expect(authMockFns.mockGetSession).not.toHaveBeenCalled()
})
it('rejects malformed workspace hints and unavailable apps before reading a session', async () => {
await expect(
SlackInstallPage({ params: Promise.resolve({ teamId: 'https://attacker.test' }) })
).rejects.toThrow('Not found')
m.app.mockReturnValue(null)
await expect(SlackInstallPage({ params: Promise.resolve({ teamId: 'T1' }) })).rejects.toThrow(
'Not found'
)
expect(authMockFns.mockGetSession).not.toHaveBeenCalled()
})
})
42 changes: 42 additions & 0 deletions apps/sim/app/slack-search/install/[teamId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ChipLink } from '@sim/emcn'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { isHosted } from '@/lib/core/config/env-flags'
import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { getSharedSlackSearchAppConfiguration } from '@/lib/slack-search/shared-app-env'
import { AuthShell } from '@/app/(auth)/components'

export const metadata: Metadata = {
title: 'Sim Search in Slack',
robots: { index: false, follow: false },
referrer: 'no-referrer',
}

interface SlackInstallPageProps {
params: Promise<{ teamId: string }>
}

export default async function SlackInstallPage({ params }: SlackInstallPageProps) {
const { teamId } = await params
const app = isHosted ? getSharedSlackSearchAppConfiguration() : null
if (!/^T[A-Z0-9]{1,199}$/.test(teamId) || !app) notFound()
const slackUrl = new URL('https://slack.com/app_redirect')
slackUrl.search = new URLSearchParams({ app: app.id, team: teamId }).toString()
return (
<AuthShell>
<div className='flex flex-col gap-5'>
<h1 className='text-2xl'>Sim Search in Slack</h1>
<p className='text-[var(--text-muted)] text-sm'>
To start searching, an admin can connect this workspace later from Settings → Sim Search
in Slack in their Sim organization.
</p>
<div className='flex gap-2'>
<ChipLink variant='primary' href={slackUrl.href}>
Open Slack
</ChipLink>
<ChipLink href={APP_ENTRY_PATH}>Open Sim</ChipLink>
</div>
</div>
</AuthShell>
)
}
2 changes: 1 addition & 1 deletion apps/sim/lib/api/contracts/knowledge/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export const startSlackSearchOAuthContract = defineRouteContract({
})

export const slackSearchOAuthCallbackQuerySchema = z.object({
state: z.string().min(1).max(200),
state: z.string().max(200).optional(),
code: z.string().min(1).max(2000).optional(),
error: z.string().min(1).max(200).optional(),
})
Expand Down
13 changes: 13 additions & 0 deletions apps/sim/lib/internal/slack/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ beforeEach(() => {
fetchMock.mockReset().mockResolvedValue(Response.json(grant))
})
describe('Slack bot OAuth exchange', () => {
it('uses the registered default callback for Slack-initiated installs and discards personal grants', async () => {
fetchMock.mockResolvedValueOnce(
Response.json({ ...grant, authed_user: { access_token: 'personal-token' } })
)
expect(
await exchangeSlackBotAuthorization({
clientId: 'client',
clientSecret: 'secret',
code: 'code',
})
).toEqual(grant)
expect(fetchMock.mock.calls[0][1].body.has('redirect_uri')).toBe(false)
})
it('exchanges a code with the same callback and client authentication', async () => {
expect(await exchangeSlackBotAuthorization(input)).toEqual(grant)
const [url, request] = fetchMock.mock.calls[0]
Expand Down
7 changes: 5 additions & 2 deletions apps/sim/lib/internal/slack/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,18 @@ export async function exchangeSlackBotAuthorization(input: {
clientId: string
clientSecret: string
code: string
redirectUri: string
redirectUri?: string
}) {
const response = await fetch('https://slack.com/api/oauth.v2.access', {
method: 'POST',
headers: {
Authorization: `Basic ${Buffer.from(`${input.clientId}:${input.clientSecret}`).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ code: input.code, redirect_uri: input.redirectUri }),
body: new URLSearchParams({
code: input.code,
...(input.redirectUri ? { redirect_uri: input.redirectUri } : {}),
}),
signal: AbortSignal.timeout(10_000),
})
const value = await readResponseJsonWithLimit<unknown>(response, {
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/lib/slack-search/install-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** The team is a selection hint; linking requires a fresh admin-bound Slack authorization. */
export function slackSearchInstallPath(teamId: string) {
if (!/^T[A-Z0-9]{1,199}$/.test(teamId)) throw new Error('Invalid Slack workspace ID')
return `/slack-search/install/${teamId}`
}
Loading
Loading