Skip to content
Open
11 changes: 9 additions & 2 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1051,7 +1051,7 @@
"hashed-api-keys-enforcement-enabled": "Secure API keys enforcement has been enabled.",
"header-event-desc": "The event type (e.g., app_versions.INSERT)",
"header-event-id-desc": "Unique identifier for this event",
"header-signature-desc": "HMAC-SHA256 signature in format v1={timestamp}.{hex_signature}",
"header-signature-desc": "Standard Webhooks HMAC-SHA256 signature in format v1,{base64_signature}",
"header-timestamp-desc": "Unix timestamp when the request was sent",
"here": "here",
"history": "History",
Expand Down Expand Up @@ -1726,7 +1726,8 @@
"signature-example-title": "Node.js verification example:",
"signature-verification-intro": "Each webhook request includes headers for signature verification:",
"signing-secret": "Signing Secret",
"signing-secret-hint": "Use this secret to verify webhook signatures. The signature is sent in the X-Capgo-Signature header.",
"signing-secret-hint": "Use this secret to verify webhook signatures for the selected delivery version.",
"signing-secret-unavailable": "Signing secrets are only shown when a webhook is created.",
"size": "Size",
"size-not-found": "Not found",
"something-went-wrong-try-again-later": "Something went wrong! Try again later.",
Expand Down Expand Up @@ -1922,6 +1923,7 @@
"webhook-created": "Webhook created successfully",
"webhook-delete-failed": "Failed to delete webhook",
"webhook-deleted": "Webhook deleted successfully",
"webhook-delivery-version": "Delivery version",
"webhook-disabled": "Webhook disabled",
"webhook-enabled": "Webhook enabled",
"webhook-events": "Events",
Expand All @@ -1938,6 +1940,10 @@
"webhook-url-https-required": "Webhook URL must use HTTPS",
"webhook-url-invalid": "Please enter a valid URL",
"webhook-url-placeholder": "https://example.com/webhook",
"webhook-version-legacy": "Compatible (legacy)",
"webhook-version-legacy-hint": "Keeps the existing Capgo payload and X-Capgo headers for current integrations.",
"webhook-version-standard": "Standard Webhooks",
"webhook-version-standard-hint": "Adds Standard Webhooks payload type and webhook-* signature headers.",
"webhooks": "Webhooks",
"webhooks-description": "Configure webhooks to receive HTTP notifications when events occur in your organization.",
"welcome-to": "Welcome to",
Expand All @@ -1964,6 +1970,7 @@
"latest-snapshot": "Latest snapshot",
"latest-day-in-selected-period": "Latest day in selected period",
"latest-day-in-selected-period-help": "{count} devices were on {version} on {date}.",
"legacy-header-signature-desc": "Capgo HMAC-SHA256 signature in format v1={timestamp}.{hex_signature}",
"selected-period": "Selected period",
"start-of-selected-period": "Start of selected period",
"start-of-selected-period-help": "{count} devices were on {version} on {date}.",
Expand Down
2 changes: 1 addition & 1 deletion src/auto-imports.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ declare global {
export type { PasswordPolicyConfig, Organization, OrganizationRole, ExtendedOrganizationMember, ExtendedOrganizationMembers } from './stores/organization'
import('./stores/organization')
// @ts-ignore
export type { DeliveryPagination, TestResult } from './stores/webhooks'
export type { DeliveryPagination, TestResult, Webhook, WebhookDeliveryVersion } from './stores/webhooks'
import('./stores/webhooks')
}

Expand Down
3 changes: 2 additions & 1 deletion src/components/WebhookDeliveryLog.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { Webhook } from '~/stores/webhooks'
import type { Database } from '~/types/supabase.types'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
Expand All @@ -16,7 +17,7 @@ import Spinner from '~/components/Spinner.vue'
import { useWebhooksStore } from '~/stores/webhooks'

const props = defineProps<{
webhook: Database['public']['Tables']['webhooks']['Row']
webhook: Webhook
}>()

const emit = defineEmits<{
Expand Down
40 changes: 35 additions & 5 deletions src/components/WebhookForm.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
<script setup lang="ts">
import type { Database } from '~/types/supabase.types'
import type { Webhook, WebhookDeliveryVersion } from '~/stores/webhooks'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconX from '~icons/heroicons/x-mark'
import { WEBHOOK_EVENT_TYPES } from '~/stores/webhooks'

const props = defineProps<{
webhook: Database['public']['Tables']['webhooks']['Row'] | null
webhook: Webhook | null
}>()

const emit = defineEmits<{
(e: 'submit', data: { name: string, url: string, events: string[], enabled: boolean }): void
(e: 'submit', data: { name: string, url: string, events: string[], enabled: boolean, deliveryVersion: WebhookDeliveryVersion }): void
(e: 'close'): void
}>()

Expand All @@ -20,7 +20,11 @@ const name = ref('')
const url = ref('')
const selectedEvents = ref<string[]>([])
const enabled = ref(true)
const deliveryVersion = ref<WebhookDeliveryVersion>('legacy')
const urlError = ref('')
const nameInputId = 'webhook-form-name'
const urlInputId = 'webhook-form-url'
const deliveryVersionInputId = 'webhook-form-delivery-version'

const isEditing = computed(() => !!props.webhook)

Expand All @@ -39,6 +43,7 @@ onMounted(() => {
url.value = props.webhook.url
selectedEvents.value = [...props.webhook.events]
enabled.value = props.webhook.enabled
deliveryVersion.value = props.webhook.delivery_version === 'standard' ? 'standard' : 'legacy'
}
})

Expand Down Expand Up @@ -81,6 +86,7 @@ function handleSubmit() {
url: url.value.trim(),
events: selectedEvents.value,
enabled: enabled.value,
deliveryVersion: deliveryVersion.value,
})
}

Expand Down Expand Up @@ -118,10 +124,11 @@ function handleBackdropClick(event: MouseEvent) {
<div class="p-4 space-y-4">
<!-- Name -->
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
<label :for="nameInputId" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('webhook-name') }} <span class="text-red-500">*</span>
</label>
<input
:id="nameInputId"
v-model="name"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
Expand All @@ -131,10 +138,11 @@ function handleBackdropClick(event: MouseEvent) {

<!-- URL -->
<div>
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
<label :for="urlInputId" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('webhook-url') }} <span class="text-red-500">*</span>
</label>
<input
:id="urlInputId"
v-model="url"
type="url"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
Expand All @@ -150,6 +158,28 @@ function handleBackdropClick(event: MouseEvent) {
</p>
</div>

<!-- Delivery Version -->
<div>
<label :for="deliveryVersionInputId" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('webhook-delivery-version') }}
</label>
<select
:id="deliveryVersionInputId"
v-model="deliveryVersion"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="legacy">
{{ t('webhook-version-legacy') }}
</option>
<option value="standard">
{{ t('webhook-version-standard') }}
</option>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ deliveryVersion === 'standard' ? t('webhook-version-standard-hint') : t('webhook-version-legacy-hint') }}
</p>
</div>

<!-- Events -->
<div>
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
Expand Down
107 changes: 81 additions & 26 deletions src/pages/settings/organization/Webhooks.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Database } from '~/types/supabase.types'
import type { Webhook } from '~/stores/webhooks'
import { computedAsync } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
Expand Down Expand Up @@ -35,9 +35,9 @@ const { currentOrganization } = storeToRefs(organizationStore)
const { webhooks, isLoading } = storeToRefs(webhooksStore)

const showForm = ref(false)
const editingWebhook = ref<Database['public']['Tables']['webhooks']['Row'] | null>(null)
const editingWebhook = ref<Webhook | null>(null)
const showDeliveryLog = ref(false)
const selectedWebhookForLog = ref<Database['public']['Tables']['webhooks']['Row'] | null>(null)
const selectedWebhookForLog = ref<Webhook | null>(null)
const testingWebhookId = ref<string | null>(null)
const expandedWebhookId = ref<string | null>(null)

Expand Down Expand Up @@ -66,7 +66,7 @@ function openCreateForm() {
showForm.value = true
}

function openEditForm(webhook: Database['public']['Tables']['webhooks']['Row']) {
function openEditForm(webhook: Webhook) {
if (!canManageWebhooks.value) {
toast.error(t('no-permission'))
return
Expand All @@ -75,7 +75,7 @@ function openEditForm(webhook: Database['public']['Tables']['webhooks']['Row'])
showForm.value = true
}

async function handleFormSubmit(data: { name: string, url: string, events: string[], enabled: boolean }) {
async function handleFormSubmit(data: { name: string, url: string, events: string[], enabled: boolean, deliveryVersion: 'legacy' | 'standard' }) {
if (editingWebhook.value) {
// When editing, pass all fields including enabled
const result = await webhooksStore.updateWebhook(editingWebhook.value.id, data)
Expand All @@ -101,7 +101,7 @@ async function handleFormSubmit(data: { name: string, url: string, events: strin
}
}

async function deleteWebhook(webhook: Database['public']['Tables']['webhooks']['Row']) {
async function deleteWebhook(webhook: Webhook) {
if (!canManageWebhooks.value) {
toast.error(t('no-permission'))
return
Expand Down Expand Up @@ -132,7 +132,7 @@ async function deleteWebhook(webhook: Database['public']['Tables']['webhooks']['
})
}

async function testWebhook(webhook: Database['public']['Tables']['webhooks']['Row']) {
async function testWebhook(webhook: Webhook) {
if (!canManageWebhooks.value) {
toast.error(t('no-permission'))
return
Expand All @@ -150,7 +150,7 @@ async function testWebhook(webhook: Database['public']['Tables']['webhooks']['Ro
}
}

async function toggleWebhook(webhook: Database['public']['Tables']['webhooks']['Row']) {
async function toggleWebhook(webhook: Webhook) {
if (!canManageWebhooks.value) {
toast.error(t('no-permission'))
return
Expand All @@ -166,7 +166,7 @@ async function toggleWebhook(webhook: Database['public']['Tables']['webhooks']['
}
}

function viewDeliveries(webhook: Database['public']['Tables']['webhooks']['Row']) {
function viewDeliveries(webhook: Webhook) {
selectedWebhookForLog.value = webhook
showDeliveryLog.value = true
}
Expand Down Expand Up @@ -202,10 +202,10 @@ async function copySecret(secret: string) {

const signatureVerificationCode = `import crypto from 'crypto'

function verifyWebhookSignature(req, secret) {
const signature = req.headers['x-capgo-signature']
const timestamp = req.headers['x-capgo-timestamp']
const payload = JSON.stringify(req.body)
function verifyWebhookSignature(rawBody, headers, secret) {
const messageId = headers['webhook-id']
const timestamp = headers['webhook-timestamp']
const signatures = headers['webhook-signature']?.split(' ') ?? []

// Check timestamp to prevent replay attacks (5 min tolerance)
const currentTime = Math.floor(Date.now() / 1000)
Expand All @@ -214,21 +214,66 @@ function verifyWebhookSignature(req, secret) {
}

// Compute expected signature
const signaturePayload = \`\${timestamp}.\${payload}\`
const hmac = crypto.createHmac('sha256', secret)
const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const signaturePayload = \`\${messageId}.\${timestamp}.\${rawBody}\`
const hmac = crypto.createHmac('sha256', secretBytes)
hmac.update(signaturePayload)
const expectedSignature = \`v1=\${timestamp}.\${hmac.digest('hex')}\`
const expectedSignature = \`v1,\${hmac.digest('base64')}\`

// Compare signatures (timing-safe)
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)) {
const isValid = signatures.some(signature =>
signature.length === expectedSignature.length
&& crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))
)

if (!isValid) {
throw new Error('Invalid webhook signature')
}

return true
}`

const legacySignatureVerificationCode = `import crypto from 'crypto'

function verifyWebhookSignature(rawBody, headers, secret) {
const signature = headers['x-capgo-signature'] ?? headers['X-Capgo-Signature']
const match = signature?.match(/^v1=(\\d+)\\.([a-f0-9]{64})$/i)

if (!match) {
throw new Error('Invalid webhook signature format')
}

const [, timestamp, receivedHmac] = match

// Check timestamp to prevent replay attacks (5 min tolerance)
const currentTime = Math.floor(Date.now() / 1000)
if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
throw new Error('Webhook timestamp too old')
}

const signaturePayload = \`\${timestamp}.\${rawBody}\`
const expectedHmac = crypto
.createHmac('sha256', secret)
.update(signaturePayload)
.digest('hex')

const isValid = receivedHmac.length === expectedHmac.length
&& crypto.timingSafeEqual(Buffer.from(receivedHmac), Buffer.from(expectedHmac))

if (!isValid) {
throw new Error('Invalid webhook signature')
}

return true
}`

function getSignatureVerificationCode(webhook: Webhook) {
return webhook.delivery_version === 'standard' ? signatureVerificationCode : legacySignatureVerificationCode
}

function getDeliveryVersionLabel(webhook: Webhook) {
return webhook.delivery_version === 'standard' ? t('webhook-version-standard') : t('webhook-version-legacy')
}
</script>

<template>
Expand Down Expand Up @@ -366,7 +411,7 @@ function verifyWebhookSignature(req, secret) {
<h4 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('signing-secret') }}
</h4>
<div class="flex items-center gap-2">
<div v-if="webhook.secret" class="flex items-center gap-2">
<code class="flex-1 px-3 py-2 font-mono text-sm text-gray-700 truncate bg-gray-100 border border-gray-200 rounded dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300">
{{ webhook.secret }}
</code>
Expand All @@ -378,6 +423,9 @@ function verifyWebhookSignature(req, secret) {
<IconClipboard class="w-4 h-4" />
</button>
</div>
<p v-else class="px-3 py-2 text-sm text-gray-600 border border-gray-200 rounded bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300">
{{ t('signing-secret-unavailable') }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('signing-secret-hint') }}
</p>
Expand All @@ -392,21 +440,28 @@ function verifyWebhookSignature(req, secret) {
{{ t('signature-verification-intro') }}
</p>
<ul class="mb-3 space-y-1 text-xs text-gray-600 list-disc list-inside dark:text-gray-400">
<li><code class="px-1 bg-gray-200 rounded dark:bg-gray-700">X-Capgo-Signature</code>: {{ t('header-signature-desc') }}</li>
<li><code class="px-1 bg-gray-200 rounded dark:bg-gray-700">X-Capgo-Timestamp</code>: {{ t('header-timestamp-desc') }}</li>
<li><code class="px-1 bg-gray-200 rounded dark:bg-gray-700">X-Capgo-Event</code>: {{ t('header-event-desc') }}</li>
<li><code class="px-1 bg-gray-200 rounded dark:bg-gray-700">X-Capgo-Event-ID</code>: {{ t('header-event-id-desc') }}</li>
<template v-if="webhook.delivery_version === 'standard'">
<li><code class="px-1 bg-gray-200 rounded dark:bg-gray-700">webhook-signature</code>: {{ t('header-signature-desc') }}</li>
<li><code class="px-1 bg-gray-200 rounded dark:bg-gray-700">webhook-timestamp</code>: {{ t('header-timestamp-desc') }}</li>
<li><code class="px-1 bg-gray-200 rounded dark:bg-gray-700">webhook-id</code>: {{ t('header-event-id-desc') }}</li>
</template>
<template v-else>
<li><code class="px-1 bg-gray-200 rounded dark:bg-gray-700">X-Capgo-Signature</code>: {{ t('legacy-header-signature-desc') }}</li>
<li><code class="px-1 bg-gray-200 rounded dark:bg-gray-700">X-Capgo-Timestamp</code>: {{ t('header-timestamp-desc') }}</li>
<li><code class="px-1 bg-gray-200 rounded dark:bg-gray-700">X-Capgo-Event-ID</code>: {{ t('header-event-id-desc') }}</li>
</template>
</ul>
<p class="mb-2 text-xs font-medium text-gray-700 dark:text-gray-300">
{{ t('signature-example-title') }}
</p>
<pre class="p-3 overflow-x-auto text-xs text-gray-100 bg-gray-900 rounded"><code>{{ signatureVerificationCode }}</code></pre>
<pre class="p-3 overflow-x-auto text-xs text-gray-100 bg-gray-900 rounded"><code>{{ getSignatureVerificationCode(webhook) }}</code></pre>
</div>
</details>
</div>

<!-- Metadata -->
<div class="mb-4 text-sm text-gray-500 dark:text-gray-400">
<p>{{ t('webhook-delivery-version') }}: {{ getDeliveryVersionLabel(webhook) }}</p>
<p>{{ t('created-at') }}: {{ formatDate(webhook.created_at) }}</p>
<p>{{ t('updated-at') }}: {{ formatDate(webhook.updated_at) }}</p>
</div>
Expand Down
Loading
Loading