diff --git a/apps/sim/app/api/webhooks/test/route.ts b/apps/sim/app/api/webhooks/test/route.ts index f0b3198d35..82182e433b 100644 --- a/apps/sim/app/api/webhooks/test/route.ts +++ b/apps/sim/app/api/webhooks/test/route.ts @@ -465,6 +465,58 @@ export async function GET(request: NextRequest) { }) } + case 'microsoftteams': { + const hmacSecret = providerConfig.hmacSecret + + if (!hmacSecret) { + logger.warn(`[${requestId}] Microsoft Teams webhook missing HMAC secret: ${webhookId}`) + return NextResponse.json( + { success: false, error: 'Microsoft Teams webhook requires HMAC secret' }, + { status: 400 } + ) + } + + logger.info(`[${requestId}] Microsoft Teams webhook test successful: ${webhookId}`) + return NextResponse.json({ + success: true, + webhook: { + id: foundWebhook.id, + url: webhookUrl, + isActive: foundWebhook.isActive, + }, + message: 'Microsoft Teams outgoing webhook configuration is valid.', + setup: { + url: webhookUrl, + hmacSecretConfigured: !!hmacSecret, + instructions: [ + 'Create an outgoing webhook in Microsoft Teams', + 'Set the callback URL to the webhook URL above', + 'Copy the HMAC security token to the configuration', + 'Users can trigger the webhook by @mentioning it in Teams', + ], + }, + test: { + curlCommand: `curl -X POST "${webhookUrl}" \\ + -H "Content-Type: application/json" \\ + -H "Authorization: HMAC " \\ + -d '{"type":"message","text":"Hello from Microsoft Teams!","from":{"id":"test","name":"Test User"}}'`, + samplePayload: { + type: 'message', + id: '1234567890', + timestamp: new Date().toISOString(), + text: 'Hello Sim Studio Bot!', + from: { + id: '29:1234567890abcdef', + name: 'Test User', + }, + conversation: { + id: '19:meeting_abcdef@thread.v2', + }, + }, + }, + }) + } + default: { // Generic webhook test logger.info(`[${requestId}] Generic webhook test successful: ${webhookId}`) diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index b69b0860c1..3db94b794c 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -11,6 +11,7 @@ import { processGenericDeduplication, processWebhook, processWhatsAppDeduplication, + validateMicrosoftTeamsSignature, } from '@/lib/webhooks/utils' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' import { db } from '@/db' @@ -243,6 +244,51 @@ export async function POST( return slackChallengeResponse } + // Handle Microsoft Teams outgoing webhook signature verification (must be done before timeout) + if (foundWebhook.provider === 'microsoftteams') { + const providerConfig = (foundWebhook.providerConfig as Record) || {} + + if (providerConfig.hmacSecret) { + const authHeader = request.headers.get('authorization') + + if (!authHeader || !authHeader.startsWith('HMAC ')) { + logger.warn( + `[${requestId}] Microsoft Teams outgoing webhook missing HMAC authorization header` + ) + return new NextResponse('Unauthorized - Missing HMAC signature', { status: 401 }) + } + + // Get the raw body for HMAC verification + const rawBody = await request.text() + + const isValidSignature = validateMicrosoftTeamsSignature( + providerConfig.hmacSecret, + authHeader, + rawBody + ) + + if (!isValidSignature) { + logger.warn(`[${requestId}] Microsoft Teams HMAC signature verification failed`) + return new NextResponse('Unauthorized - Invalid HMAC signature', { status: 401 }) + } + + logger.debug(`[${requestId}] Microsoft Teams HMAC signature verified successfully`) + + // Parse the body again since we consumed it for verification + try { + body = JSON.parse(rawBody) + } catch (parseError) { + logger.error( + `[${requestId}] Failed to parse Microsoft Teams webhook body after verification`, + { + error: parseError instanceof Error ? parseError.message : String(parseError), + } + ) + return new NextResponse('Invalid JSON payload', { status: 400 }) + } + } + } + // Skip processing if another instance is already handling this request if (!hasExecutionLock) { logger.info(`[${requestId}] Skipping execution as lock was not acquired`) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/microsoftteams.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/microsoftteams.tsx new file mode 100644 index 0000000000..163d958902 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/providers/microsoftteams.tsx @@ -0,0 +1,130 @@ +import { Shield, Terminal } from 'lucide-react' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { CodeBlock } from '@/components/ui/code-block' +import { Input } from '@/components/ui/input' +import { ConfigField } from '../ui/config-field' +import { ConfigSection } from '../ui/config-section' +import { InstructionsSection } from '../ui/instructions-section' +import { TestResultDisplay } from '../ui/test-result' + +interface MicrosoftTeamsConfigProps { + hmacSecret: string + setHmacSecret: (secret: string) => void + isLoadingToken: boolean + testResult: { + success: boolean + message?: string + test?: any + } | null + copied: string | null + copyToClipboard: (text: string, type: string) => void + testWebhook: () => Promise +} + +const teamsWebhookExample = JSON.stringify( + { + type: 'message', + id: '1234567890', + timestamp: '2023-01-01T00:00:00.000Z', + localTimestamp: '2023-01-01T00:00:00.000Z', + serviceUrl: 'https://smba.trafficmanager.net/amer/', + channelId: 'msteams', + from: { + id: '29:1234567890abcdef', + name: 'John Doe', + }, + conversation: { + id: '19:meeting_abcdef@thread.v2', + }, + text: 'Hello Sim Studio Bot!', + }, + null, + 2 +) + +export function MicrosoftTeamsConfig({ + hmacSecret, + setHmacSecret, + isLoadingToken, + testResult, + copied, + copyToClipboard, + testWebhook, +}: MicrosoftTeamsConfigProps) { + return ( +
+ + + setHmacSecret(e.target.value)} + placeholder='Enter HMAC secret from Teams' + disabled={isLoadingToken} + type='password' + /> + + + + + + +
    +
  1. Open Microsoft Teams and go to the team where you want to add the webhook.
  2. +
  3. Click the three dots (•••) next to the team name and select "Manage team".
  4. +
  5. Go to the "Apps" tab and click "Create an outgoing webhook".
  6. +
  7. Provide a name, description, and optionally a profile picture.
  8. +
  9. Set the callback URL to your Sim Studio webhook URL (shown above).
  10. +
  11. Copy the HMAC security token and paste it into the "HMAC Secret" field above.
  12. +
  13. Click "Create" to finish setup.
  14. +
+
+ + +

+ When users mention your webhook in Teams (using @mention), Teams will send a POST request + to your Sim Studio webhook URL with a payload like this: +

+ +
    +
  • Messages are triggered by @mentioning the webhook name in Teams.
  • +
  • Requests include HMAC signature for authentication.
  • +
  • You have 5 seconds to respond to the webhook request.
  • +
+
+ + + + Security + + The HMAC secret is used to verify that requests are actually coming from Microsoft Teams. + Keep it secure and never share it publicly. + + + + + + Requirements + +
    +
  • Your Sim Studio webhook URL must use HTTPS and be publicly accessible.
  • +
  • Self-signed SSL certificates are not supported by Microsoft Teams.
  • +
  • For local testing, use a tunneling service like ngrok or Cloudflare Tunnel.
  • +
+
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx index 0c34ba2bab..6c476e8df6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/webhook/components/webhook-modal.tsx @@ -15,6 +15,7 @@ import { DiscordConfig } from './providers/discord' import { GenericConfig } from './providers/generic' import { GithubConfig } from './providers/github' import { GmailConfig } from './providers/gmail' +import { MicrosoftTeamsConfig } from './providers/microsoftteams' import { SlackConfig } from './providers/slack' import { StripeConfig } from './providers/stripe' import { TelegramConfig } from './providers/telegram' @@ -79,6 +80,8 @@ export function WebhookModal({ const [discordAvatarUrl, setDiscordAvatarUrl] = useState('') const [slackSigningSecret, setSlackSigningSecret] = useState('') const [telegramBotToken, setTelegramBotToken] = useState('') + // Microsoft Teams-specific state + const [microsoftTeamsHmacSecret, setMicrosoftTeamsHmacSecret] = useState('') // Airtable-specific state const [airtableWebhookSecret, _setAirtableWebhookSecret] = useState('') const [airtableBaseId, setAirtableBaseId] = useState('') @@ -103,6 +106,7 @@ export function WebhookModal({ airtableTableId: '', airtableIncludeCellValues: false, telegramBotToken: '', + microsoftTeamsHmacSecret: '', selectedLabels: ['INBOX'] as string[], labelFilterBehavior: 'INCLUDE', markAsRead: false, @@ -259,6 +263,15 @@ export function WebhookModal({ includeRawEmail: config.includeRawEmail, })) } + } else if (webhookProvider === 'microsoftteams') { + const hmacSecret = config.hmacSecret || '' + + setMicrosoftTeamsHmacSecret(hmacSecret) + + setOriginalValues((prev) => ({ + ...prev, + microsoftTeamsHmacSecret: hmacSecret, + })) } } } @@ -303,7 +316,9 @@ export function WebhookModal({ !originalValues.selectedLabels.every((label) => selectedLabels.includes(label)) || labelFilterBehavior !== originalValues.labelFilterBehavior || markAsRead !== originalValues.markAsRead || - includeRawEmail !== originalValues.includeRawEmail)) + includeRawEmail !== originalValues.includeRawEmail)) || + (webhookProvider === 'microsoftteams' && + microsoftTeamsHmacSecret !== originalValues.microsoftTeamsHmacSecret) setHasUnsavedChanges(hasChanges) }, [ @@ -327,6 +342,7 @@ export function WebhookModal({ labelFilterBehavior, markAsRead, includeRawEmail, + microsoftTeamsHmacSecret, ]) // Validate required fields for current provider @@ -354,6 +370,9 @@ export function WebhookModal({ case 'gmail': isValid = selectedLabels.length > 0 break + case 'microsoftteams': + isValid = microsoftTeamsHmacSecret.trim() !== '' + break } setIsCurrentConfigValid(isValid) }, [ @@ -364,6 +383,7 @@ export function WebhookModal({ whatsappVerificationToken, telegramBotToken, selectedLabels, + microsoftTeamsHmacSecret, ]) // Use the provided path or generate a UUID-based path @@ -433,6 +453,10 @@ export function WebhookModal({ return { botToken: telegramBotToken || undefined, } + case 'microsoftteams': + return { + hmacSecret: microsoftTeamsHmacSecret, + } default: return {} } @@ -482,6 +506,7 @@ export function WebhookModal({ airtableTableId, airtableIncludeCellValues, telegramBotToken, + microsoftTeamsHmacSecret, selectedLabels, labelFilterBehavior, markAsRead, @@ -727,6 +752,18 @@ export function WebhookModal({ webhookUrl={webhookUrl} /> ) + case 'microsoftteams': + return ( + + ) default: return ( // Define available webhook providers @@ -280,6 +286,20 @@ export const WEBHOOK_PROVIDERS: { [key: string]: WebhookProvider } = { }, }, }, + microsoftteams: { + id: 'microsoftteams', + name: 'Microsoft Teams', + icon: (props) => , + configFields: { + hmacSecret: { + type: 'string', + label: 'HMAC Secret', + placeholder: 'Enter HMAC secret from Teams outgoing webhook', + description: + 'The security token provided by Teams when creating an outgoing webhook. Used to verify request authenticity.', + }, + }, + }, } interface WebhookConfigProps { diff --git a/apps/sim/blocks/blocks/webhook.ts b/apps/sim/blocks/blocks/webhook.ts index 2f361e4f5a..d8b3bfbac0 100644 --- a/apps/sim/blocks/blocks/webhook.ts +++ b/apps/sim/blocks/blocks/webhook.ts @@ -3,6 +3,7 @@ import { DiscordIcon, GithubIcon, GmailIcon, + MicrosoftTeamsIcon, SignalIcon, SlackIcon, StripeIcon, @@ -23,6 +24,7 @@ const getWebhookProviderIcon = (provider: string) => { github: GithubIcon, discord: DiscordIcon, stripe: StripeIcon, + microsoftteams: MicrosoftTeamsIcon, } return iconMap[provider.toLowerCase()] @@ -52,6 +54,7 @@ export const WebhookBlock: BlockConfig = { 'github', 'discord', 'stripe', + 'microsoftteams', ].map((provider) => { const providerLabels = { slack: 'Slack', @@ -63,6 +66,7 @@ export const WebhookBlock: BlockConfig = { github: 'GitHub', discord: 'Discord', stripe: 'Stripe', + microsoftteams: 'Microsoft Teams', } const icon = getWebhookProviderIcon(provider) diff --git a/apps/sim/lib/webhooks/utils.ts b/apps/sim/lib/webhooks/utils.ts index 0aee84245d..a60ca4277a 100644 --- a/apps/sim/lib/webhooks/utils.ts +++ b/apps/sim/lib/webhooks/utils.ts @@ -400,6 +400,61 @@ export function formatWebhookInput( } return body } + + if (foundWebhook.provider === 'microsoftteams') { + // Microsoft Teams outgoing webhook - Teams sending data to us + const messageText = body?.text || '' + const messageId = body?.id || '' + const timestamp = body?.timestamp || body?.localTimestamp || '' + const from = body?.from || {} + const conversation = body?.conversation || {} + + return { + input: messageText, // Primary workflow input - the message text + microsoftteams: { + message: { + id: messageId, + text: messageText, + timestamp, + type: body?.type || 'message', + serviceUrl: body?.serviceUrl, + channelId: body?.channelId, + raw: body, + }, + from: { + id: from.id, + name: from.name, + aadObjectId: from.aadObjectId, + }, + conversation: { + id: conversation.id, + name: conversation.name, + conversationType: conversation.conversationType, + tenantId: conversation.tenantId, + }, + activity: { + type: body?.type, + id: body?.id, + timestamp: body?.timestamp, + localTimestamp: body?.localTimestamp, + serviceUrl: body?.serviceUrl, + channelId: body?.channelId, + }, + }, + webhook: { + data: { + provider: 'microsoftteams', + path: foundWebhook.path, + providerConfig: foundWebhook.providerConfig, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } + } + // Generic format for Slack and other providers return { webhook: { @@ -790,6 +845,54 @@ export async function executeWorkflowFromPayload( } } +/** + * Validates a Microsoft Teams outgoing webhook request signature using HMAC SHA-256 + * @param hmacSecret - Microsoft Teams HMAC secret (base64 encoded) + * @param signature - Authorization header value (should start with 'HMAC ') + * @param body - Raw request body string + * @returns Whether the signature is valid + */ +export function validateMicrosoftTeamsSignature( + hmacSecret: string, + signature: string, + body: string +): boolean { + try { + // Basic validation first + if (!hmacSecret || !signature || !body) { + return false + } + + // Check if signature has correct format + if (!signature.startsWith('HMAC ')) { + return false + } + + const providedSignature = signature.substring(5) // Remove 'HMAC ' prefix + + // Compute HMAC SHA256 signature using Node.js crypto + const crypto = require('crypto') + const secretBytes = Buffer.from(hmacSecret, 'base64') + const bodyBytes = Buffer.from(body, 'utf8') + const computedHash = crypto.createHmac('sha256', secretBytes).update(bodyBytes).digest('base64') + + // Constant-time comparison to prevent timing attacks + if (computedHash.length !== providedSignature.length) { + return false + } + + let result = 0 + for (let i = 0; i < computedHash.length; i++) { + result |= computedHash.charCodeAt(i) ^ providedSignature.charCodeAt(i) + } + + return result === 0 + } catch (error) { + console.error('Error validating Microsoft Teams signature:', error) + return false + } +} + /** * Process webhook provider-specific verification */ @@ -850,6 +953,10 @@ export function verifyProviderWebhook( break } + case 'microsoftteams': + // Microsoft Teams webhook authentication is handled separately in the main flow + // due to the need for raw body access for HMAC verification + break case 'generic': // Generic auth logic: requireAuth, token, secretHeaderName, allowedIps if (providerConfig.requireAuth) { @@ -1350,10 +1457,10 @@ export async function processWebhook( return NextResponse.json({ message: 'Airtable webhook processed' }, { status: 200 }) } - // --- Provider-specific Auth/Verification (excluding Airtable/WhatsApp/Slack handled earlier) --- + // --- Provider-specific Auth/Verification (excluding Airtable/WhatsApp/Slack/MicrosoftTeams handled earlier) --- if ( foundWebhook.provider && - !['airtable', 'whatsapp', 'slack'].includes(foundWebhook.provider) + !['airtable', 'whatsapp', 'slack', 'microsoftteams'].includes(foundWebhook.provider) ) { const verificationResponse = verifyProviderWebhook(foundWebhook, request, requestId) if (verificationResponse) {