Files
simstudioai--sim/apps/sim/lib/webhooks/providers/clerk.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

175 lines
6.1 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Base64 } from '@sim/security/hmac'
import { NextResponse } from 'next/server'
import type {
AuthContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
const logger = createLogger('WebhookProvider:Clerk')
/**
* Verify a Clerk webhook signature using the Svix signing scheme.
* Clerk uses Svix under the hood: HMAC-SHA256 of `${svix-id}.${svix-timestamp}.${body}`
* signed with the base64-decoded `whsec_...` secret, compared against the
* space-delimited, versioned (`v1,<sig>`) `svix-signature` header.
*/
function verifySvixSignature(
secret: string,
msgId: string,
timestamp: string,
signatures: string,
rawBody: string
): boolean {
try {
const ts = Number.parseInt(timestamp, 10)
const now = Math.floor(Date.now() / 1000)
if (Number.isNaN(ts) || Math.abs(now - ts) > 5 * 60) {
return false
}
const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const toSign = `${msgId}.${timestamp}.${rawBody}`
const expectedSignature = hmacSha256Base64(toSign, secretBytes)
const providedSignatures = signatures.split(' ')
for (const versionedSig of providedSignatures) {
const parts = versionedSig.split(',')
if (parts.length !== 2) continue
const sig = parts[1]
if (safeCompare(sig, expectedSignature)) {
return true
}
}
return false
} catch (error) {
logger.error('Error verifying Clerk Svix signature:', error)
return false
}
}
export const clerkHandler: WebhookProviderHandler = {
async verifyAuth({
request,
rawBody,
requestId,
providerConfig,
}: AuthContext): Promise<NextResponse | null> {
const signingSecret = providerConfig.signingSecret as string | undefined
if (!signingSecret?.trim()) {
logger.warn(`[${requestId}] Clerk webhook missing signing secret in provider configuration`)
return new NextResponse('Unauthorized - Clerk signing secret is required', { status: 401 })
}
const svixId = request.headers.get('svix-id')
const svixTimestamp = request.headers.get('svix-timestamp')
const svixSignature = request.headers.get('svix-signature')
if (!svixId || !svixTimestamp || !svixSignature) {
logger.warn(`[${requestId}] Clerk webhook missing Svix signature headers`)
return new NextResponse('Unauthorized - Missing Clerk signature headers', { status: 401 })
}
if (!verifySvixSignature(signingSecret, svixId, svixTimestamp, svixSignature, rawBody)) {
logger.warn(`[${requestId}] Clerk Svix signature verification failed`)
return new NextResponse('Unauthorized - Invalid Clerk signature', { status: 401 })
}
return null
},
async matchEvent({ body, providerConfig, requestId }: EventMatchContext): Promise<boolean> {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId || triggerId === 'clerk_webhook') {
return true
}
const { isClerkEventMatch } = await import('@/triggers/clerk/utils')
if (!isClerkEventMatch(triggerId, body as Record<string, unknown>)) {
const actualType = (body as Record<string, unknown>)?.type
logger.debug(
`[${requestId}] Clerk event type mismatch for trigger ${triggerId}, got ${String(actualType)}. Skipping.`
)
return false
}
return true
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const payload = body as Record<string, unknown>
const type = (payload.type as string | undefined) ?? ''
const data = (payload.data as Record<string, unknown> | undefined) ?? undefined
const isUser = type.startsWith('user.')
const isSession = type.startsWith('session.')
const isMembership = type.startsWith('organizationMembership.')
const isOrganization = type.startsWith('organization.') && !isMembership
const organization = data?.organization as Record<string, unknown> | undefined
const publicUserData = data?.public_user_data as Record<string, unknown> | undefined
const userId = isUser
? (data?.id ?? null)
: isSession
? (data?.user_id ?? null)
: isMembership
? (publicUserData?.user_id ?? null)
: null
const organizationId = isMembership
? (organization?.id ?? null)
: isOrganization
? (data?.id ?? null)
: null
return {
input: {
type: payload.type ?? null,
object: payload.object ?? null,
timestamp: payload.timestamp ?? null,
instance_id: payload.instance_id ?? null,
data: data ?? null,
userId,
sessionId: isSession ? (data?.id ?? null) : null,
organizationId,
membershipId: isMembership ? (data?.id ?? null) : null,
firstName: data?.first_name ?? null,
lastName: data?.last_name ?? null,
username: data?.username ?? null,
imageUrl: data?.image_url ?? null,
primaryEmailAddressId: data?.primary_email_address_id ?? null,
emailAddresses: data?.email_addresses ?? null,
phoneNumbers: data?.phone_numbers ?? null,
externalId: data?.external_id ?? null,
deleted: data?.deleted ?? null,
status: data?.status ?? null,
clientId: data?.client_id ?? null,
role: data?.role ?? null,
name: data?.name ?? null,
slug: data?.slug ?? null,
createdBy: data?.created_by ?? null,
membersCount: data?.members_count ?? null,
maxAllowedMemberships: data?.max_allowed_memberships ?? null,
createdAt: data?.created_at ?? null,
updatedAt: data?.updated_at ?? null,
},
}
},
extractIdempotencyId(body: unknown): string | null {
const payload = body as Record<string, unknown>
const type = payload?.type as string | undefined
const data = payload?.data as Record<string, unknown> | undefined
const dataId = data?.id as string | undefined
if (type && dataId) {
return `${type}:${dataId}`
}
return null
},
}