feat(webhook): added slack webhook support for slack events (#164)

* feat[webhook]: add Slack integration with signing secret configuration #111

* feat[webhook]: add Slack configuration component for webhook integration

Introduced a new SlackConfig component to manage the Slack app signing secret and provide setup instructions. This component includes input fields for the signing secret, a test result display, and detailed setup guidance for integrating with Slack's event subscriptions.

* feat[webhook]: implement Slack signature validation helper function

* feat[webhook]: refactor POST request to handle slack requests.

Also handeling verification challenge from slack for url verification.

* feat[webhook]: Add Slack provider configuration
support to webhook modal

* feat[webhook]: Enhance Slack webhook testing functionality

Implement test functionality of slack webhook, #111

* fix(webhook):  Improve logica error, removed OR condition on checking the availability of signingSecret #164

* feat(webhook): move Slack signature validation to a dedicated utility file
This commit is contained in:
Ayaan Kaifullah
2025-03-26 03:36:52 -07:00
committed by GitHub
parent 51e2b39c56
commit ea42d6ee6c
8 changed files with 356 additions and 7 deletions
+49
View File
@@ -224,6 +224,55 @@ export async function GET(request: NextRequest) {
})
}
case 'slack': {
const signingSecret = providerConfig.signingSecret
if (!signingSecret) {
logger.warn(`[${requestId}] Slack webhook missing signing secret: ${webhookId}`)
return NextResponse.json(
{ success: false, error: 'Webhook has no signing secret configured' },
{ status: 400 }
)
}
logger.info(`[${requestId}] Slack webhook test successful: ${webhookId}`)
return NextResponse.json({
success: true,
webhook: {
id: foundWebhook.id,
url: webhookUrl,
isActive: foundWebhook.isActive,
},
message:
'Slack webhook configuration is valid. Use this URL in your Slack Event Subscriptions settings.',
setup: {
url: webhookUrl,
events: ['message.channels', 'reaction_added', 'app_mention'],
signingSecretConfigured: true,
},
test: {
curlCommand: [
`curl -X POST "${webhookUrl}"`,
`-H "Content-Type: application/json"`,
`-H "X-Slack-Request-Timestamp: $(date +%s)"`,
`-H "X-Slack-Signature: v0=$(date +%s)"`,
`-d '{"type":"event_callback","event":{"type":"message","channel":"C0123456789","user":"U0123456789","text":"Hello from Slack!","ts":"1234567890.123456"},"team_id":"T0123456789"}'`
].join(' \\\n'),
samplePayload: {
"type": "event_callback",
"token": "XXYYZZ",
"team_id": "T123ABC",
"event": {
"type": "message",
"user": "U123ABC",
"text": "Hello from Slack!",
"ts": "1234567890.1234"
},
"event_id": "Ev123ABC"
}
}
})
}
default: {
// Generic webhook test
logger.info(`[${requestId}] Generic webhook test successful: ${webhookId}`)
+71 -4
View File
@@ -12,6 +12,7 @@ import { db } from '@/db'
import { environment, userStats, webhook, workflow } from '@/db/schema'
import { Executor } from '@/executor'
import { Serializer } from '@/serializer'
import {validateSlackSignature} from '../../utils'
const logger = createLogger('WebhookTriggerAPI')
@@ -113,12 +114,18 @@ export async function POST(
const requestId = crypto.randomUUID().slice(0, 8)
const executionId = uuidv4()
let foundWorkflow: any = null
let rawBody: string | null = null
try {
const path = (await params).path
// Clone the request to get both the raw body for Slack signature verification
// and the parsed JSON body for processing
const requestClone = request.clone()
rawBody = await requestClone.text()
// Parse the request body
const body = await request.json().catch(() => ({}))
const body = JSON.parse(rawBody || '{}')
logger.info(`[${requestId}] Webhook POST request received for path: ${path}`)
// Generate a unique request ID based on the request content
@@ -156,8 +163,68 @@ export async function POST(
workflowId: foundWorkflow.id,
})
// For WhatsApp, also check for duplicate messages using their message ID
if (foundWebhook.provider === 'whatsapp') {
// Provider-specific validation and handling
if (foundWebhook.provider === 'slack') {
// Validate Slack signature if this is a Slack webhook
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
const signingSecret = providerConfig.signingSecret
if (signingSecret) {
const slackSignature = request.headers.get('x-slack-signature')
const slackTimestamp = request.headers.get('x-slack-request-timestamp')
if (!slackSignature || !slackTimestamp || !rawBody) {
logger.warn(`[${requestId}] Missing Slack signature headers`, {
hasSignature: !!slackSignature,
hasTimestamp: !!slackTimestamp,
hasBody: !!rawBody
})
return NextResponse.json({ error: 'Invalid Slack request' }, { status: 400 })
}
// Validate the Slack signature
const isValid = await validateSlackSignature(
signingSecret,
slackSignature,
slackTimestamp,
rawBody
)
if (!isValid) {
logger.warn(`[${requestId}] Invalid Slack signature`)
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
logger.info(`[${requestId}] Slack signature validated successfully`)
// Handle Slack URL verification challenge during POST
if (body.type === 'url_verification' && body.challenge) {
logger.info(`[${requestId}] Responding to Slack URL verification challenge`)
return NextResponse.json({ challenge: body.challenge })
}
}
// Check if we've already processed this message using Redis
const messageId = body?.event?.event_id
if (messageId && (await hasProcessedMessage(messageId))) {
logger.info(`[${requestId}] Duplicate Slack message detected with ID: ${messageId}`)
// Return early for duplicate messages to prevent workflow execution
return new NextResponse('Duplicate message', { status: 200 })
}
// Store the message ID in Redis to prevent duplicate processing in future requests
if (messageId) {
await markMessageAsProcessed(messageId)
}
// Mark this request as processed to prevent duplicates
await markMessageAsProcessed(requestHash, 60 * 60 * 24)
// Process the webhook for Slack
return await processWebhook(foundWebhook, foundWorkflow, body, request, executionId, requestId)
} else if (foundWebhook.provider === 'whatsapp') {
// Extract WhatsApp specific data
const data = body?.entry?.[0]?.changes?.[0]?.value
const messages = data?.messages || []
+70
View File
@@ -0,0 +1,70 @@
/**
* Validates a Slack webhook request signature using HMAC SHA-256
* @param signingSecret - Slack signing secret for validation
* @param signature - X-Slack-Signature header value
* @param timestamp - X-Slack-Request-Timestamp header value
* @param body - Raw request body string
* @returns Whether the signature is valid
*/
export async function validateSlackSignature(
signingSecret: string,
signature: string,
timestamp: string,
body: string
): Promise<boolean> {
try {
// Basic validation first
if (!signingSecret || !signature || !timestamp || !body) {
return false
}
// Check if the timestamp is too old (> 5 minutes)
const currentTime = Math.floor(Date.now() / 1000)
if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
return false
}
// Compute the signature
const encoder = new TextEncoder()
const baseString = `v0:${timestamp}:${body}`
// Create the HMAC with the signing secret
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(signingSecret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const signatureBytes = await crypto.subtle.sign(
'HMAC',
key,
encoder.encode(baseString)
)
// Convert the signature to hex
const signatureHex = Array.from(new Uint8Array(signatureBytes))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
// Prepare the expected signature format
const computedSignature = `v0=${signatureHex}`
// Constant-time comparison to prevent timing attacks
if (computedSignature.length !== signature.length) {
return false
}
let result = 0
for (let i = 0; i < computedSignature.length; i++) {
result |= computedSignature.charCodeAt(i) ^ signature.charCodeAt(i)
}
return result === 0
} catch (error) {
console.error('Error validating Slack signature:', error)
return false
}
}
@@ -0,0 +1,119 @@
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { CopyableField } from '../ui/copyable'
import { TestResultDisplay } from '../ui/test-result'
interface SlackConfigProps {
signingSecret: string
setSigningSecret: (secret: string) => void
isLoadingToken: boolean
testResult: {
success: boolean
message?: string
test?: any
} | null
copied: string | null
copyToClipboard: (text: string, type: string) => void
testWebhook: () => Promise<void>
}
export function SlackConfig({
signingSecret,
setSigningSecret,
isLoadingToken,
testResult,
copied,
copyToClipboard,
}: SlackConfigProps) {
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="slack-signing-secret">Signing Secret</Label>
<CopyableField
id="slack-signing-secret"
label="Signing Secret"
value={signingSecret}
onChange={setSigningSecret}
placeholder="Enter your Slack app signing secret"
description="The signing secret from your Slack app used to validate request authenticity."
isLoading={isLoadingToken}
copied={copied}
copyType="slack-signing-secret"
copyToClipboard={copyToClipboard}
/>
<p className="text-xs text-muted-foreground">
The signing secret is provided in your Slack app&apos;s Basic Information page.
</p>
</div>
<TestResultDisplay
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
showCurlCommand={true}
/>
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-md mt-3 border border-gray-200 dark:border-gray-700">
<h4 className="font-medium">Setup Instructions</h4>
<ol className="list-decimal list-inside space-y-1 text-sm">
<li>Go to your <a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">Slack Apps page</a></li>
<li>Create a new app or select an existing one</li>
<li>Navigate to &quot;Event Subscriptions&quot; in the left sidebar</li>
<li>Enable events and add the Webhook URL above as the Request URL</li>
<li>Add the event subscriptions you want to receive (e.g., message.channels)</li>
<li>Go to &quot;Basic Information&quot; and copy your Signing Secret</li>
<li>Paste the Signing Secret in the field above</li>
<li>Save your configuration</li>
</ol>
</div>
<div className="bg-emerald-50 dark:bg-emerald-950 p-3 rounded-md mt-3 border border-emerald-200 dark:border-emerald-800">
<h5 className="text-sm font-medium text-emerald-800 dark:text-emerald-300">
Slack Webhook Features
</h5>
<ul className="mt-1 space-y-1">
<li className="flex items-start">
<span className="text-emerald-500 dark:text-emerald-400 mr-2">•</span>
<span className="text-sm text-emerald-700 dark:text-emerald-300">
Receive events from Slack channels, direct messages, and more
</span>
</li>
<li className="flex items-start">
<span className="text-emerald-500 dark:text-emerald-400 mr-2">•</span>
<span className="text-sm text-emerald-700 dark:text-emerald-300">
Trigger workflows based on messages, reactions, or other Slack events
</span>
</li>
<li className="flex items-start">
<span className="text-emerald-500 dark:text-emerald-400 mr-2">•</span>
<span className="text-sm text-emerald-700 dark:text-emerald-300">
Securely verify incoming requests with Slack&apos;s signing secret
</span>
</li>
</ul>
</div>
<div className="bg-purple-50 dark:bg-purple-950 p-3 rounded-md mt-3 border border-purple-200 dark:border-purple-800">
<h5 className="text-sm font-medium text-purple-800 dark:text-purple-300">
Example Slack Event
</h5>
<pre className="mt-2 text-xs bg-black/5 dark:bg-white/5 p-2 rounded overflow-x-auto">
{`{
"type": "event_callback",
"event": {
"type": "message",
"channel": "C0123456789",
"user": "U0123456789",
"text": "Hello from Slack!",
"ts": "1234567890.123456"
},
"team_id": "T0123456789",
"event_id": "Ev0123456789",
"event_time": 1234567890
}`}
</pre>
</div>
</div>
)
}
@@ -28,7 +28,7 @@ export function WebhookDialogFooter({
onClose,
}: WebhookDialogFooterProps) {
const showTestButton =
webhookId && (webhookProvider === 'whatsapp' || webhookProvider === 'generic') && onTest
webhookId && (webhookProvider === 'whatsapp' || webhookProvider === 'generic' || webhookProvider === "slack") && onTest
return (
<DialogFooter className="flex justify-between sticky bottom-0 py-3 bg-background border-t z-10 mt-auto w-full">
@@ -12,6 +12,7 @@ import { UnsavedChangesDialog } from './ui/confirmation'
import { WebhookDialogFooter } from './ui/webhook-footer'
import { WebhookDialogHeader } from './ui/webhook-header'
import { WebhookUrlField } from './ui/webhook-url'
import { SlackConfig } from './providers/slack-config'
const logger = createLogger('WebhookModal')
@@ -69,6 +70,7 @@ export function WebhookModal({
const [githubContentType, setGithubContentType] = useState('application/json')
const [discordWebhookName, setDiscordWebhookName] = useState('')
const [discordAvatarUrl, setDiscordAvatarUrl] = useState('')
const [slackSigningSecret, setSlackSigningSecret] = useState('')
// Original values to track changes
const [originalValues, setOriginalValues] = useState({
@@ -80,6 +82,7 @@ export function WebhookModal({
allowedIps: '',
discordWebhookName: '',
discordAvatarUrl: '',
slackSigningSecret: '',
})
// Get the current provider configuration
@@ -174,6 +177,10 @@ export function WebhookModal({
requireAuth: auth,
allowedIps: ips,
}))
} else if (webhookProvider === 'slack' && 'signingSecret' in config) {
const signingSecret = config.signingSecret || ''
setSlackSigningSecret(signingSecret)
setOriginalValues((prev) => ({ ...prev, slackSigningSecret: signingSecret }))
}
}
}
@@ -205,7 +212,9 @@ export function WebhookModal({
(generalToken !== originalValues.generalToken ||
secretHeaderName !== originalValues.secretHeaderName ||
requireAuth !== originalValues.requireAuth ||
allowedIps !== originalValues.allowedIps))
allowedIps !== originalValues.allowedIps)) ||
(webhookProvider === 'slack' &&
slackSigningSecret !== originalValues.slackSigningSecret)
setHasUnsavedChanges(hasChanges)
}, [
@@ -219,6 +228,7 @@ export function WebhookModal({
requireAuth,
allowedIps,
originalValues,
slackSigningSecret,
])
// Use the provided path or generate a UUID-based path
@@ -266,6 +276,8 @@ export function WebhookModal({
requireAuth,
allowedIps: parsedIps.length > 0 ? parsedIps : undefined,
}
case 'slack':
return { signingSecret: slackSigningSecret }
default:
return {}
}
@@ -296,6 +308,7 @@ export function WebhookModal({
allowedIps,
discordWebhookName,
discordAvatarUrl,
slackSigningSecret,
})
setHasUnsavedChanges(false)
}
@@ -435,6 +448,18 @@ export function WebhookModal({
copyToClipboard={copyToClipboard}
/>
)
case 'slack':
return (
<SlackConfig
signingSecret={slackSigningSecret}
setSigningSecret={setSlackSigningSecret}
isLoadingToken={isLoadingToken}
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
testWebhook={testWebhook}
/>
)
case 'generic':
default:
return (
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { CheckCircle2, ExternalLink } from 'lucide-react'
import { DiscordIcon, GithubIcon, StripeIcon, WhatsAppIcon } from '@/components/icons'
import { DiscordIcon, GithubIcon, StripeIcon, WhatsAppIcon, SlackIcon } from '@/components/icons'
import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console-logger'
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
@@ -50,6 +50,10 @@ export interface GeneralWebhookConfig {
allowedIps?: string[]
}
export interface SlackConfig {
signingSecret: string
}
// Union type for all provider configurations
export type ProviderConfig =
| WhatsAppConfig
@@ -57,6 +61,7 @@ export type ProviderConfig =
| DiscordConfig
| StripeConfig
| GeneralWebhookConfig
| SlackConfig
| Record<string, never>
// Define available webhook providers
@@ -145,6 +150,19 @@ export const WEBHOOK_PROVIDERS: { [key: string]: WebhookProvider } = {
},
},
},
slack: {
id: 'slack',
name: 'Slack',
icon: (props) => <SlackIcon {...props} />,
configFields: {
signingSecret: {
type: 'string',
label: 'Signing Secret',
placeholder: 'Enter your Slack app signing secret',
description: 'The signing secret from your Slack app to validate request authenticity.',
},
},
},
}
interface WebhookConfigProps {
+1
View File
@@ -42,6 +42,7 @@ export const StarterBlock: BlockConfig<StarterBlockOutput> = {
{ label: 'WhatsApp', id: 'whatsapp' },
{ label: 'GitHub', id: 'github' },
{ label: 'Discord', id: 'discord' },
{ label: 'Slack', id: 'slack' },
// { label: 'Stripe', id: 'stripe' },
],
value: () => 'generic',