diff --git a/apps/docs/content/docs/search/slack.mdx b/apps/docs/content/docs/search/slack.mdx index 26544966a73..4d540e11521 100644 --- a/apps/docs/content/docs/search/slack.mdx +++ b/apps/docs/content/docs/search/slack.mdx @@ -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. Install Sim Search using the shared Slack app diff --git a/apps/sim/app/api/knowledge/slack/oauth/callback/route.test.ts b/apps/sim/app/api/knowledge/slack/oauth/callback/route.test.ts new file mode 100644 index 00000000000..59b74751968 --- /dev/null +++ b/apps/sim/app/api/knowledge/slack/oauth/callback/route.test.ts @@ -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()), + 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() + }) +}) diff --git a/apps/sim/app/api/knowledge/slack/oauth/callback/route.ts b/apps/sim/app/api/knowledge/slack/oauth/callback/route.ts index c9f507625e2..b6155d6f5c3 100644 --- a/apps/sim/app/api/knowledge/slack/oauth/callback/route.ts +++ b/apps/sim/app/api/knowledge/slack/oauth/callback/route.ts @@ -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( @@ -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 })) diff --git a/apps/sim/app/slack-search/install/[teamId]/page.test.tsx b/apps/sim/app/slack-search/install/[teamId]/page.test.tsx new file mode 100644 index 00000000000..32b82014180 --- /dev/null +++ b/apps/sim/app/slack-search/install/[teamId]/page.test.tsx @@ -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'>) => {children}, +})) +vi.mock('@/app/(auth)/components', () => ({ + AuthShell: ({ children }: { children: ReactNode }) =>
{children}
, +})) +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&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() + }) +}) diff --git a/apps/sim/app/slack-search/install/[teamId]/page.tsx b/apps/sim/app/slack-search/install/[teamId]/page.tsx new file mode 100644 index 00000000000..98b4cd34894 --- /dev/null +++ b/apps/sim/app/slack-search/install/[teamId]/page.tsx @@ -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 ( + +
+

Sim Search in Slack

+

+ To start searching, an admin can connect this workspace later from Settings → Sim Search + in Slack in their Sim organization. +

+
+ + Open Slack + + Open Sim +
+
+
+ ) +} diff --git a/apps/sim/lib/api/contracts/knowledge/slack.ts b/apps/sim/lib/api/contracts/knowledge/slack.ts index f957b04eb3e..71751e603c4 100644 --- a/apps/sim/lib/api/contracts/knowledge/slack.ts +++ b/apps/sim/lib/api/contracts/knowledge/slack.ts @@ -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(), }) diff --git a/apps/sim/lib/internal/slack/oauth.test.ts b/apps/sim/lib/internal/slack/oauth.test.ts index 9e4deeb48cf..a8d5d6cca6f 100644 --- a/apps/sim/lib/internal/slack/oauth.test.ts +++ b/apps/sim/lib/internal/slack/oauth.test.ts @@ -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] diff --git a/apps/sim/lib/internal/slack/oauth.ts b/apps/sim/lib/internal/slack/oauth.ts index ced0697443a..8d1f1dcccb1 100644 --- a/apps/sim/lib/internal/slack/oauth.ts +++ b/apps/sim/lib/internal/slack/oauth.ts @@ -23,7 +23,7 @@ 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', @@ -31,7 +31,10 @@ export async function exchangeSlackBotAuthorization(input: { 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(response, { diff --git a/apps/sim/lib/slack-search/install-link.ts b/apps/sim/lib/slack-search/install-link.ts new file mode 100644 index 00000000000..11e0e7c2421 --- /dev/null +++ b/apps/sim/lib/slack-search/install-link.ts @@ -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}` +} diff --git a/apps/sim/lib/slack-search/public-install-auth.test.ts b/apps/sim/lib/slack-search/public-install-auth.test.ts new file mode 100644 index 00000000000..be556dd5201 --- /dev/null +++ b/apps/sim/lib/slack-search/public-install-auth.test.ts @@ -0,0 +1,70 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SLACK_SHARED_SEARCH_BOT_SCOPES } from '@/lib/slack-search/constants' + +const m = vi.hoisted(() => ({ app: vi.fn(), exchange: vi.fn(), hosted: true })) +vi.mock('@/lib/core/config/env-flags', () => ({ + get isHosted() { + return m.hosted + }, +})) +vi.mock('@/lib/slack-search/shared-app-env', () => ({ + getSharedSlackSearchAppConfiguration: m.app, +})) +vi.mock('@/lib/internal/slack/oauth', async (importOriginal) => ({ + ...(await importOriginal()), + exchangeSlackBotAuthorization: m.exchange, +})) + +import { authenticateSlackPublicInstallation } from '@/lib/slack-search/public-install-auth' + +const grant = { + ok: true, + app_id: 'A1', + token_type: 'bot', + access_token: 'bot-token', + bot_user_id: 'UBOT', + scope: SLACK_SHARED_SEARCH_BOT_SCOPES.join(','), + team: { id: 'T1', name: 'Test' }, +} +beforeEach(() => { + vi.clearAllMocks() + m.hosted = true + m.app.mockReturnValue({ id: 'A1', clientId: 'client', clientSecret: 'secret', revision: 'r1' }) + m.exchange.mockResolvedValue(grant) +}) +describe('Slack-initiated installation authentication', () => { + it('completes the Slack install without returning tokens or asserting a Sim identity', async () => { + const result = await authenticateSlackPublicInstallation('one-use-code') + expect(m.exchange).toHaveBeenCalledWith({ + clientId: 'client', + clientSecret: 'secret', + code: 'one-use-code', + }) + expect(result).toEqual({ teamId: 'T1' }) + }) + it.each([ + { app_id: 'A2' }, + { scope: 'chat:write' }, + { is_enterprise_install: true }, + { refresh_token: 'refresh' }, + ])('rejects incompatible grants: %j', async (change) => { + m.exchange.mockResolvedValue({ ...grant, ...change }) + await expect(authenticateSlackPublicInstallation('code')).rejects.toThrow() + }) + it('fails on an expired or replayed provider code', async () => { + m.exchange.mockRejectedValue(new Error('Slack authorization failed')) + await expect(authenticateSlackPublicInstallation('used-code')).rejects.toThrow( + 'Slack authorization failed' + ) + }) + it.each(['self-hosted', 'unconfigured'])( + 'rejects %s deployments before exchange', + async (deployment) => { + if (deployment === 'self-hosted') m.hosted = false + else m.app.mockReturnValue(null) + await expect(authenticateSlackPublicInstallation('code')).rejects.toThrow('unavailable') + expect(m.exchange).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/lib/slack-search/public-install-auth.ts b/apps/sim/lib/slack-search/public-install-auth.ts new file mode 100644 index 00000000000..6535c17fd4d --- /dev/null +++ b/apps/sim/lib/slack-search/public-install-auth.ts @@ -0,0 +1,26 @@ +import { isHosted } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + exchangeSlackBotAuthorization, + validateSlackBotAuthorization, +} from '@/lib/internal/slack/oauth' +import { SLACK_SHARED_SEARCH_BOT_SCOPES } from '@/lib/slack-search/constants' +import { getSharedSlackSearchAppConfiguration } from '@/lib/slack-search/shared-app-env' + +/** + * Completes installation in Slack without binding it to Sim or retaining tokens. + * Organization setup later obtains its own grant through the admin's state-bound OAuth flow. + */ +export async function authenticateSlackPublicInstallation(code: string) { + const app = isHosted ? getSharedSlackSearchAppConfiguration() : null + if (!app) throw new OrchestrationError('forbidden', 'The Sim Search app is unavailable') + const grant = await exchangeSlackBotAuthorization({ + clientId: app.clientId, + clientSecret: app.clientSecret, + code, + }) + validateSlackBotAuthorization(grant, SLACK_SHARED_SEARCH_BOT_SCOPES) + if (grant.app_id !== app.id) + throw new OrchestrationError('forbidden', 'Slack returned a different app') + return { teamId: grant.team.id } +}