Skip to content
Open
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
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